diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-21 18:15:17 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-21 18:15:17 +0000 |
commit | 248492cc573e85aea19d7493c3a15d459be016c5 (patch) | |
tree | c25388f4af2e9a87e06121318982001b964e7573 | |
parent | 97a128c1d1bf45bcc00d5fae037f840eff1ae4e0 (diff) | |
download | gitlab-ce-248492cc573e85aea19d7493c3a15d459be016c5.tar.gz |
Add latest changes from gitlab-org/gitlab@master
133 files changed, 2169 insertions, 1762 deletions
diff --git a/.gitlab/ci/notify.gitlab-ci.yml b/.gitlab/ci/notify.gitlab-ci.yml index 795a0cd6439..7fbecd8944b 100644 --- a/.gitlab/ci/notify.gitlab-ci.yml +++ b/.gitlab/ci/notify.gitlab-ci.yml @@ -60,7 +60,7 @@ notify-pipeline-failure: echosuccess "Created incident $(jq '.web_url' ${INCIDENT_JSON})"; fi - | - scripts/generate-failed-pipeline-slack-message.rb -i ${INCIDENT_JSON} -f ${FAILED_PIPELINE_SLACK_MESSAGE_FILE}; + scripts/generate-failed-pipeline-slack-message.rb -p ${INCIDENT_PROJECT} -i ${INCIDENT_JSON} -f ${FAILED_PIPELINE_SLACK_MESSAGE_FILE}; curl -X POST -H 'Content-Type: application/json' --data @${FAILED_PIPELINE_SLACK_MESSAGE_FILE} "$CI_SLACK_WEBHOOK_URL" || scripts/slack ${SLACK_CHANNEL} "☠️ Broken pipeline notification failed! ☠️ See ${CI_JOB_URL}" ci_failing "Failed pipeline reporter"; diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 6bd7542bcde..bdb9b9a3151 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -4,6 +4,7 @@ default: stages: - prepare - deploy + - post-deploy - qa - post-qa - dast @@ -144,6 +145,34 @@ review-deploy: expire_in: 7 days when: always +review-deploy-failure-notification: + stage: post-deploy + image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION} + rules: + - when: on_failure + allow_failure: true + variables: + BROKEN_REVIEW_APPS_INCIDENTS_PROJECT: "gitlab-org/quality/engineering-productivity/review-apps-broken-incidents" + BROKEN_REVIEW_APPS_INCIDENT_JSON: "${CI_PROJECT_DIR}/incident.json" + SLACK_CHANNEL: "review-apps-broken" + FAILED_PIPELINE_SLACK_MESSAGE_FILE: "${CI_PROJECT_DIR}/failed_pipeline_slack_message.json" + before_script: + - source scripts/utils.sh + - apt-get update && apt-get install -y jq + - install_gitlab_gem + script: + # Check that the review-deploy failed. If not, we don't do anything. + # It can be possible that other jobs were failing, but here we only + # care about review-deploy + - curl "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?scope=failed" | jq -e 'any(.name == "review-deploy")' + - | + scripts/create-pipeline-failure-incident.rb -p ${BROKEN_REVIEW_APPS_INCIDENTS_PROJECT} -f ${BROKEN_REVIEW_APPS_INCIDENT_JSON} -t ${BROKEN_REVIEW_APPS_INCIDENTS_PROJECT_TOKEN}; + echosuccess "Created incident $(jq '.web_url' ${BROKEN_REVIEW_APPS_INCIDENT_JSON})"; + - | + scripts/generate-failed-pipeline-slack-message.rb -p ${BROKEN_REVIEW_APPS_INCIDENTS_PROJECT} -i ${BROKEN_REVIEW_APPS_INCIDENT_JSON} -f ${FAILED_PIPELINE_SLACK_MESSAGE_FILE}; + curl -X POST -H 'Content-Type: application/json' --data @${FAILED_PIPELINE_SLACK_MESSAGE_FILE} "$CI_SLACK_WEBHOOK_URL" || + scripts/slack ${SLACK_CHANNEL} "☠️ Broken pipeline notification failed! ☠️ See ${CI_JOB_URL}" ci_failing "Failed pipeline reporter" + review-deploy-sample-projects: extends: - .review-workflow-base diff --git a/.rubocop_todo/gitlab/namespaced_class.yml b/.rubocop_todo/gitlab/namespaced_class.yml index d1257e3ffac..a35783ac898 100644 --- a/.rubocop_todo/gitlab/namespaced_class.yml +++ b/.rubocop_todo/gitlab/namespaced_class.yml @@ -199,7 +199,6 @@ Gitlab/NamespacedClass: - 'app/models/issue_email_participant.rb' - 'app/models/issue_link.rb' - 'app/models/issue_user_mention.rb' - - 'app/models/iteration.rb' - 'app/models/jira_connect_installation.rb' - 'app/models/jira_connect_subscription.rb' - 'app/models/jira_import_state.rb' @@ -917,6 +916,7 @@ Gitlab/NamespacedClass: - 'ee/app/models/issuable_metric_image.rb' - 'ee/app/models/issuable_sla.rb' - 'ee/app/models/issuables_analytics.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/iteration_note.rb' - 'ee/app/models/ldap_group_link.rb' - 'ee/app/models/ldap_key.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index cc2252ce579..f6750d917ec 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -1092,7 +1092,6 @@ Layout/LineLength: - 'ee/app/models/ee/group.rb' - 'ee/app/models/ee/integrations/jira.rb' - 'ee/app/models/ee/issue.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/key.rb' - 'ee/app/models/ee/lfs_object.rb' - 'ee/app/models/ee/list.rb' @@ -1123,6 +1122,7 @@ Layout/LineLength: - 'ee/app/models/incident_management/oncall_rotation.rb' - 'ee/app/models/integrations/github.rb' - 'ee/app/models/issuable_sla.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/iterations/cadence.rb' - 'ee/app/models/license.rb' - 'ee/app/models/merge_requests/compliance_violation.rb' @@ -1378,6 +1378,7 @@ Layout/LineLength: - 'ee/lib/api/resource_iteration_events.rb' - 'ee/lib/api/status_checks.rb' - 'ee/lib/api/vulnerability_issue_links.rb' + - 'ee/lib/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/api/deployments.rb' - 'ee/lib/ee/api/entities/application_setting.rb' - 'ee/lib/ee/api/entities/dependency.rb' @@ -1404,7 +1405,6 @@ Layout/LineLength: - 'ee/lib/ee/api/merge_request_approvals.rb' - 'ee/lib/ee/api/merge_requests.rb' - 'ee/lib/ee/api/namespaces.rb' - - 'ee/lib/ee/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/gitlab/analytics/cycle_analytics/aggregated/base_query_builder.rb' - 'ee/lib/ee/gitlab/analytics/cycle_analytics/data_collector.rb' - 'ee/lib/ee/gitlab/analytics/cycle_analytics/stage_events.rb' @@ -2076,7 +2076,6 @@ Layout/LineLength: - 'ee/spec/models/ee/group_spec.rb' - 'ee/spec/models/ee/incident_management/project_incident_management_setting_spec.rb' - 'ee/spec/models/ee/integrations/jira_spec.rb' - - 'ee/spec/models/ee/iteration_spec.rb' - 'ee/spec/models/ee/iterations/cadence_spec.rb' - 'ee/spec/models/ee/lfs_object_spec.rb' - 'ee/spec/models/ee/merge_request_diff_spec.rb' @@ -2115,6 +2114,7 @@ Layout/LineLength: - 'ee/spec/models/integrations/chat_message/vulnerability_message_spec.rb' - 'ee/spec/models/issuable_sla_spec.rb' - 'ee/spec/models/issue_spec.rb' + - 'ee/spec/models/iteration_spec.rb' - 'ee/spec/models/license_spec.rb' - 'ee/spec/models/member_spec.rb' - 'ee/spec/models/merge_request_spec.rb' diff --git a/.rubocop_todo/layout/space_in_lambda_literal.yml b/.rubocop_todo/layout/space_in_lambda_literal.yml index 362d9b20eb1..c311f562e93 100644 --- a/.rubocop_todo/layout/space_in_lambda_literal.yml +++ b/.rubocop_todo/layout/space_in_lambda_literal.yml @@ -202,7 +202,6 @@ Layout/SpaceInLambdaLiteral: - 'ee/app/models/ee/group.rb' - 'ee/app/models/ee/group_group_link.rb' - 'ee/app/models/ee/issue.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/list.rb' - 'ee/app/models/ee/member.rb' - 'ee/app/models/ee/namespace.rb' @@ -220,6 +219,7 @@ Layout/SpaceInLambdaLiteral: - 'ee/app/models/incident_management/oncall_rotation.rb' - 'ee/app/models/incident_management/oncall_schedule.rb' - 'ee/app/models/incident_management/oncall_shift.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/iterations/cadence.rb' - 'ee/app/models/merge_request_block.rb' - 'ee/app/models/merge_requests/compliance_violation.rb' diff --git a/.rubocop_todo/layout/space_inside_parens.yml b/.rubocop_todo/layout/space_inside_parens.yml index de88cdc49bc..56b37ba768d 100644 --- a/.rubocop_todo/layout/space_inside_parens.yml +++ b/.rubocop_todo/layout/space_inside_parens.yml @@ -26,7 +26,6 @@ Layout/SpaceInsideParens: - 'ee/spec/models/boards/epic_board_position_spec.rb' - 'ee/spec/models/dora/change_failure_rate_metric_spec.rb' - 'ee/spec/models/ee/integrations/jira_spec.rb' - - 'ee/spec/models/ee/iteration_spec.rb' - 'ee/spec/models/ee/iterations/cadence_spec.rb' - 'ee/spec/models/ee/key_spec.rb' - 'ee/spec/models/ee/project_setting_spec.rb' @@ -35,6 +34,7 @@ Layout/SpaceInsideParens: - 'ee/spec/models/geo/every_geo_event_spec.rb' - 'ee/spec/models/incident_management/escalation_rule_spec.rb' - 'ee/spec/models/ip_restriction_spec.rb' + - 'ee/spec/models/iteration_spec.rb' - 'ee/spec/models/ldap_group_link_spec.rb' - 'ee/spec/models/license_spec.rb' - 'ee/spec/models/member_spec.rb' diff --git a/.rubocop_todo/lint/unused_method_argument.yml b/.rubocop_todo/lint/unused_method_argument.yml index a4e48b35248..4841ca8f590 100644 --- a/.rubocop_todo/lint/unused_method_argument.yml +++ b/.rubocop_todo/lint/unused_method_argument.yml @@ -264,10 +264,10 @@ Lint/UnusedMethodArgument: - 'ee/app/models/concerns/geo/repository_replicator_strategy.rb' - 'ee/app/models/concerns/geo/verifiable_replicator.rb' - 'ee/app/models/concerns/geo/verification_state.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/member.rb' - 'ee/app/models/ee/project.rb' - 'ee/app/models/group_wiki.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/iteration_note.rb' - 'ee/app/replicators/geo/container_repository_replicator.rb' - 'ee/app/replicators/geo/pipeline_replicator.rb' @@ -294,7 +294,6 @@ Lint/UnusedMethodArgument: - 'ee/lib/ee/api/ci/helpers/runner.rb' - 'ee/lib/ee/api/entities/project.rb' - 'ee/lib/ee/backup/repositories.rb' - - 'ee/lib/ee/banzai/reference_parser/iteration_parser.rb' - 'ee/lib/ee/gitlab/auth/ldap/sync/proxy.rb' - 'ee/lib/ee/gitlab/geo_git_access.rb' - 'ee/lib/ee/gitlab/tracking.rb' diff --git a/.rubocop_todo/performance/map_compact.yml b/.rubocop_todo/performance/map_compact.yml index f47388609ea..5f2331d6edd 100644 --- a/.rubocop_todo/performance/map_compact.yml +++ b/.rubocop_todo/performance/map_compact.yml @@ -65,8 +65,8 @@ Performance/MapCompact: - 'ee/app/services/vulnerabilities/findings/find_or_create_from_security_finding_service.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - 'ee/db/fixtures/development/30_customizable_cycle_analytics.rb' + - 'ee/lib/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/api/entities/experiment.rb' - - 'ee/lib/ee/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/gitlab/auth/ldap/person.rb' - 'ee/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids.rb' - 'ee/lib/ee/gitlab/background_migration/recalculate_vulnerability_finding_signatures_for_findings.rb' diff --git a/.rubocop_todo/rails/inverse_of.yml b/.rubocop_todo/rails/inverse_of.yml index 752b1d9b4d1..de45a47fed6 100644 --- a/.rubocop_todo/rails/inverse_of.yml +++ b/.rubocop_todo/rails/inverse_of.yml @@ -29,7 +29,6 @@ Rails/InverseOf: - 'app/models/group_group_link.rb' - 'app/models/group_label.rb' - 'app/models/incident_management/timeline_event.rb' - - 'app/models/issue.rb' - 'app/models/jira_connect_subscription.rb' - 'app/models/members/group_member.rb' - 'app/models/members/project_member.rb' @@ -62,7 +61,6 @@ Rails/InverseOf: - 'ee/app/models/ee/clusters/agent.rb' - 'ee/app/models/ee/epic.rb' - 'ee/app/models/ee/group.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/merge_request.rb' - 'ee/app/models/ee/plan.rb' - 'ee/app/models/ee/project.rb' @@ -79,6 +77,7 @@ Rails/InverseOf: - 'ee/app/models/incident_management/oncall_participant.rb' - 'ee/app/models/insight.rb' - 'ee/app/models/integrations/gitlab_slack_application.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/requirements_management/requirement.rb' - 'ee/app/models/requirements_management/test_report.rb' - 'ee/app/models/sbom/vulnerable_component_version.rb' diff --git a/.rubocop_todo/rails/pluck.yml b/.rubocop_todo/rails/pluck.yml index 7e4af1ef3f9..7eb70947497 100644 --- a/.rubocop_todo/rails/pluck.yml +++ b/.rubocop_todo/rails/pluck.yml @@ -25,7 +25,7 @@ Rails/Pluck: - 'ee/app/workers/geo/repository_shard_sync_worker.rb' - 'ee/app/workers/geo/repository_verification/secondary/shard_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - - 'ee/lib/ee/banzai/filter/references/iteration_reference_filter.rb' + - 'ee/lib/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/gitlab/auth/ldap/person.rb' - 'ee/lib/ee/gitlab/background_migration/delete_invalid_epic_issues.rb' - 'ee/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings.rb' diff --git a/.rubocop_todo/rails/redundant_foreign_key.yml b/.rubocop_todo/rails/redundant_foreign_key.yml index 8705236aaf9..9824c78a8fc 100644 --- a/.rubocop_todo/rails/redundant_foreign_key.yml +++ b/.rubocop_todo/rails/redundant_foreign_key.yml @@ -44,7 +44,6 @@ Rails/RedundantForeignKey: - 'ee/app/models/ci/sources/project.rb' - 'ee/app/models/concerns/incident_management/base_pending_escalation.rb' - 'ee/app/models/deployments/approval.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/service_desk_setting.rb' - 'ee/app/models/geo/event_log.rb' - 'ee/app/models/incident_management/escalation_rule.rb' @@ -53,6 +52,7 @@ Rails/RedundantForeignKey: - 'ee/app/models/incident_management/pending_escalations/alert.rb' - 'ee/app/models/incident_management/pending_escalations/issue.rb' - 'ee/app/models/issuable_metric_image.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/security/orchestration_policy_configuration.rb' - 'ee/app/models/security/orchestration_policy_rule_schedule.rb' - 'ee/app/models/vulnerabilities/feedback.rb' diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml index cc005fa9121..d3a55c5d9c5 100644 --- a/.rubocop_todo/rspec/context_wording.yml +++ b/.rubocop_todo/rspec/context_wording.yml @@ -463,7 +463,6 @@ RSpec/ContextWording: - 'ee/spec/models/ee/group_group_link_spec.rb' - 'ee/spec/models/ee/group_spec.rb' - 'ee/spec/models/ee/incident_management/project_incident_management_setting_spec.rb' - - 'ee/spec/models/ee/iteration_spec.rb' - 'ee/spec/models/ee/iterations/cadence_spec.rb' - 'ee/spec/models/ee/namespace_ci_cd_setting_spec.rb' - 'ee/spec/models/ee/namespace_spec.rb' @@ -495,6 +494,7 @@ RSpec/ContextWording: - 'ee/spec/models/issuable_sla_spec.rb' - 'ee/spec/models/issue_link_spec.rb' - 'ee/spec/models/issue_spec.rb' + - 'ee/spec/models/iteration_spec.rb' - 'ee/spec/models/license_spec.rb' - 'ee/spec/models/member_spec.rb' - 'ee/spec/models/merge_request/blocking_spec.rb' diff --git a/.rubocop_todo/rspec/described_class.yml b/.rubocop_todo/rspec/described_class.yml index 7dcf1838282..3a505885c9a 100644 --- a/.rubocop_todo/rspec/described_class.yml +++ b/.rubocop_todo/rspec/described_class.yml @@ -13,7 +13,6 @@ RSpec/DescribedClass: - 'ee/spec/models/ee/ci/runner_spec.rb' - 'ee/spec/models/ee/gpg_key_spec.rb' - 'ee/spec/models/ee/group_spec.rb' - - 'ee/spec/models/ee/iteration_spec.rb' - 'ee/spec/models/ee/project_spec.rb' - 'ee/spec/models/ee/vulnerability_spec.rb' - 'ee/spec/models/epic_issue_spec.rb' @@ -25,6 +24,7 @@ RSpec/DescribedClass: - 'ee/spec/models/geo/secondary_usage_data_spec.rb' - 'ee/spec/models/issuable_metric_image_spec.rb' - 'ee/spec/models/issue_spec.rb' + - 'ee/spec/models/iteration_spec.rb' - 'ee/spec/models/license_spec.rb' - 'ee/spec/models/merge_train_spec.rb' - 'ee/spec/models/project_import_state_spec.rb' diff --git a/.rubocop_todo/rspec/scattered_let.yml b/.rubocop_todo/rspec/scattered_let.yml index f1cb325bd50..8e3a2895d04 100644 --- a/.rubocop_todo/rspec/scattered_let.yml +++ b/.rubocop_todo/rspec/scattered_let.yml @@ -24,9 +24,9 @@ RSpec/ScatteredLet: - 'ee/spec/models/approval_wrapped_any_approver_rule_spec.rb' - 'ee/spec/models/dast_site_validation_spec.rb' - 'ee/spec/models/ee/ci/build_dependencies_spec.rb' - - 'ee/spec/models/ee/iteration_spec.rb' - 'ee/spec/models/ee/user_spec.rb' - 'ee/spec/models/epic_spec.rb' + - 'ee/spec/models/iteration_spec.rb' - 'ee/spec/models/preloaders/environments/protected_environment_preloader_spec.rb' - 'ee/spec/models/vulnerabilities/historical_statistic_spec.rb' - 'ee/spec/requests/api/analytics/project_deployment_frequency_spec.rb' diff --git a/.rubocop_todo/style/guard_clause.yml b/.rubocop_todo/style/guard_clause.yml index 91c1999836e..3c123b25953 100644 --- a/.rubocop_todo/style/guard_clause.yml +++ b/.rubocop_todo/style/guard_clause.yml @@ -312,7 +312,6 @@ Style/GuardClause: - 'ee/app/models/ee/incident_management/issuable_escalation_status.rb' - 'ee/app/models/ee/issue.rb' - 'ee/app/models/ee/issue_assignee.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/member.rb' - 'ee/app/models/ee/merge_request.rb' - 'ee/app/models/ee/namespace.rb' @@ -328,6 +327,7 @@ Style/GuardClause: - 'ee/app/models/incident_management/escalation_rule.rb' - 'ee/app/models/incident_management/oncall_rotation.rb' - 'ee/app/models/ip_restriction.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/namespace_limit.rb' - 'ee/app/models/preloaders/environments/protected_environment_preloader.rb' - 'ee/app/models/protected_environment.rb' diff --git a/.rubocop_todo/style/if_unless_modifier.yml b/.rubocop_todo/style/if_unless_modifier.yml index 19016646725..208729cbce8 100644 --- a/.rubocop_todo/style/if_unless_modifier.yml +++ b/.rubocop_todo/style/if_unless_modifier.yml @@ -454,7 +454,6 @@ Style/IfUnlessModifier: - 'ee/app/models/ee/group.rb' - 'ee/app/models/ee/group_member.rb' - 'ee/app/models/ee/issue.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/key.rb' - 'ee/app/models/ee/list.rb' - 'ee/app/models/ee/milestone_release.rb' @@ -467,6 +466,7 @@ Style/IfUnlessModifier: - 'ee/app/models/geo/tracking_base.rb' - 'ee/app/models/incident_management/escalation_rule.rb' - 'ee/app/models/ip_restriction.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/merge_requests/external_status_check.rb' - 'ee/app/models/requirements_management/requirement.rb' - 'ee/app/models/requirements_management/test_report.rb' @@ -586,6 +586,7 @@ Style/IfUnlessModifier: - 'ee/lib/api/merge_request_approval_rules.rb' - 'ee/lib/api/protected_environments.rb' - 'ee/lib/audit/details.rb' + - 'ee/lib/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/api/entities/epic.rb' - 'ee/lib/ee/api/entities/experiment.rb' - 'ee/lib/ee/api/geo.rb' @@ -595,7 +596,6 @@ Style/IfUnlessModifier: - 'ee/lib/ee/api/internal/base.rb' - 'ee/lib/ee/api/merge_request_approvals.rb' - 'ee/lib/ee/api/settings.rb' - - 'ee/lib/ee/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/container_registry/client.rb' - 'ee/lib/ee/gitlab/auth/ldap/access.rb' - 'ee/lib/ee/gitlab/auth/ldap/group.rb' diff --git a/.rubocop_todo/style/redundant_freeze.yml b/.rubocop_todo/style/redundant_freeze.yml index cda2972c60c..e9ec1e101d7 100644 --- a/.rubocop_todo/style/redundant_freeze.yml +++ b/.rubocop_todo/style/redundant_freeze.yml @@ -66,10 +66,10 @@ Style/RedundantFreeze: - 'ee/app/graphql/types/incident_management/oncall_rotation_date_input_type.rb' - 'ee/app/models/allowed_email_domain.rb' - 'ee/app/models/ee/issue.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/label.rb' - 'ee/app/models/ee/project_import_state.rb' - 'ee/app/models/ee/vulnerability.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/status_page/project_setting.rb' - 'ee/app/serializers/analytics/cycle_analytics/value_stream_errors_serializer.rb' - 'ee/app/services/elastic/data_migration_service.rb' diff --git a/.rubocop_todo/style/redundant_regexp_escape.yml b/.rubocop_todo/style/redundant_regexp_escape.yml index 61592f8f4cd..b1ab72bac61 100644 --- a/.rubocop_todo/style/redundant_regexp_escape.yml +++ b/.rubocop_todo/style/redundant_regexp_escape.yml @@ -28,8 +28,8 @@ Style/RedundantRegexpEscape: - 'config/routes/snippets.rb' - 'config/routes/uploads.rb' - 'ee/app/models/ee/epic.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/vulnerability.rb' + - 'ee/app/models/iteration.rb' - 'ee/config/routes/admin.rb' - 'ee/lib/ee/gitlab/path_regex.rb' - 'ee/lib/elastic/latest/merge_request_class_proxy.rb' diff --git a/.rubocop_todo/style/redundant_self.yml b/.rubocop_todo/style/redundant_self.yml index 9441150d6d3..5335915271d 100644 --- a/.rubocop_todo/style/redundant_self.yml +++ b/.rubocop_todo/style/redundant_self.yml @@ -198,7 +198,6 @@ Style/RedundantSelf: - 'ee/app/models/ee/group.rb' - 'ee/app/models/ee/group_member.rb' - 'ee/app/models/ee/issue.rb' - - 'ee/app/models/ee/iteration.rb' - 'ee/app/models/ee/member.rb' - 'ee/app/models/ee/namespace.rb' - 'ee/app/models/ee/packages/package_file.rb' @@ -217,6 +216,7 @@ Style/RedundantSelf: - 'ee/app/models/gitlab_subscription.rb' - 'ee/app/models/gitlab_subscriptions/upcoming_reconciliation.rb' - 'ee/app/models/group_wiki_repository.rb' + - 'ee/app/models/iteration.rb' - 'ee/app/models/iterations/cadence.rb' - 'ee/app/models/license.rb' - 'ee/app/models/merge_requests/external_status_check.rb' @@ -34,7 +34,7 @@ gem 'sprockets', '~> 3.7.0' gem 'view_component', '~> 2.74.1' # Supported DBs -gem 'pg', '~> 1.4.5' +gem 'pg', '~> 1.4.6' gem 'rugged', '~> 1.5' gem 'grape-path-helpers', '~> 1.7.1' @@ -210,7 +210,7 @@ gem 'diffy', '~> 3.4' gem 'diff_match_patch', '~> 0.1.0' # Application server -gem 'rack', '~> 2.2.6', '>= 2.2.6.2' +gem 'rack', '~> 2.2.6', '>= 2.2.6.4' # https://github.com/zombocom/rack-timeout/blob/master/README.md#rails-apps-manually gem 'rack-timeout', '~> 0.6.3', require: 'rack/timeout/base' @@ -371,7 +371,7 @@ gem 'prometheus-client-mmap', '~> 0.19', require: 'prometheus/client' gem 'warning', '~> 1.3.0' group :development do - gem 'lefthook', '~> 1.3.3', require: false + gem 'lefthook', '~> 1.3.5', require: false gem 'rubocop' gem 'solargraph', '~> 0.47.2', require: false diff --git a/Gemfile.checksum b/Gemfile.checksum index 08ee1355967..2aeb3a40570 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -325,7 +325,7 @@ {"name":"kramdown-parser-gfm","version":"1.1.0","platform":"ruby","checksum":"fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729"}, {"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"}, {"name":"launchy","version":"2.5.0","platform":"ruby","checksum":"954243c4255920982ce682f89a42e76372dba94770bf09c23a523e204bdebef5"}, -{"name":"lefthook","version":"1.3.3","platform":"ruby","checksum":"8269a799d0abad6aaf188edb66a661c729abe6b74f3d8d660529d51f9ed2dc5d"}, +{"name":"lefthook","version":"1.3.5","platform":"ruby","checksum":"0e1e44763ff578c75a69fb44d2c24bad45b01ad9946925f110176e8c3dcd2443"}, {"name":"letter_opener","version":"1.7.0","platform":"ruby","checksum":"095bc0d58e006e5b43ea7d219e64ecf2de8d1f7d9dafc432040a845cf59b4725"}, {"name":"letter_opener_web","version":"2.0.0","platform":"ruby","checksum":"33860ad41e1785d75456500e8ca8bba8ed71ee6eaf08a98d06bbab67c5577b6f"}, {"name":"libyajl2","version":"1.2.0","platform":"ruby","checksum":"1117cd1e48db013b626e36269bbf1cef210538ca6d2e62d3fa3db9ded005b258"}, @@ -431,10 +431,10 @@ {"name":"parslet","version":"1.8.2","platform":"ruby","checksum":"08d1ab3721cd3f175bfbee8788b2ddff71f92038f2d69bd65454c22bb9fbd98a"}, {"name":"pastel","version":"0.8.0","platform":"ruby","checksum":"481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75"}, {"name":"peek","version":"1.1.0","platform":"ruby","checksum":"d6501ead8cde46d8d8ed0d59eb6f0ba713d0a41c11a2c4a81447b2dce37b3ecc"}, -{"name":"pg","version":"1.4.5","platform":"ruby","checksum":"c139bd34907e7bbe3291a9b5e651bcf00de1f8a99a3148c064bc2d1b10b5a6f1"}, -{"name":"pg","version":"1.4.5","platform":"x64-mingw-ucrt","checksum":"614814a4597fed5c4a85e107a96ef6c8ee01b3e7dbc6529912249b7d778e5651"}, -{"name":"pg","version":"1.4.5","platform":"x64-mingw32","checksum":"d9a15cb4ee478bf719fee6ecd6c8b41d5569515ee0d968e561fe120aed862cb1"}, -{"name":"pg","version":"1.4.5","platform":"x86-mingw32","checksum":"255764ff8ac89203cc9dcc7188a4205e760fa7b884d75c94007b79897ee8613d"}, +{"name":"pg","version":"1.4.6","platform":"ruby","checksum":"d98f3dcb4a6ae29780a2219340cb0e55dbafbb7eb4ccc2b99f892f2569a7a61e"}, +{"name":"pg","version":"1.4.6","platform":"x64-mingw-ucrt","checksum":"1efb4f932d5579b87b1c37e0ef49d101925d4f0e3fcf282569aed0382a522b68"}, +{"name":"pg","version":"1.4.6","platform":"x64-mingw32","checksum":"26c4a010fe2cefe61f56f0c4ba9a86b6e99d0965af100f30eaba1602a167af56"}, +{"name":"pg","version":"1.4.6","platform":"x86-mingw32","checksum":"14376f8a122ec58b9e1b4123774e7eafb59222544b7c6cfaa379c6ef28785ae6"}, {"name":"pg_query","version":"2.2.1","platform":"ruby","checksum":"6086972bbf4eab86d8425b35f14ca8b6fe41e4341423582801c1ec86ff5f8cea"}, {"name":"plist","version":"3.6.0","platform":"ruby","checksum":"f468bcf6b72ec6d1585ed6744eb4817c1932a5bf91895ed056e69b7f12ca10f2"}, {"name":"png_quantizator","version":"0.2.1","platform":"ruby","checksum":"6023d4d064125c3a7e02929c95b7320ed6ac0d7341f9e8de0c9ea6576ef3106b"}, @@ -456,7 +456,7 @@ {"name":"raabro","version":"1.4.0","platform":"ruby","checksum":"d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882"}, {"name":"racc","version":"1.6.2","platform":"java","checksum":"0880781e7dfde09e665d0b6160b583e01ed52fcc2955d7891447d33c2d1d2cf1"}, {"name":"racc","version":"1.6.2","platform":"ruby","checksum":"58d26b3666382396fea84d33dc0639b7ee8d704156a52f8f22681f07b2f94f26"}, -{"name":"rack","version":"2.2.6.2","platform":"ruby","checksum":"4be320c0fdea6651f0247dbd4182c1bd8acc06606a6b8935a19ad6a504347763"}, +{"name":"rack","version":"2.2.6.4","platform":"ruby","checksum":"d3d92be402b5881058caccc0975e6d67a1e0ba929d1d144a43daf689169bfce1"}, {"name":"rack-accept","version":"0.4.5","platform":"ruby","checksum":"66247b5449db64ebb93ae2ec4af4764b87d1ae8a7463c7c68893ac13fa8d4da2"}, {"name":"rack-attack","version":"6.6.1","platform":"ruby","checksum":"187e5d248c6a162ed8cafa8241a7b5947d9b9cf122a4870eb1cdd0db861f3a11"}, {"name":"rack-cors","version":"1.1.1","platform":"ruby","checksum":"4702644ac6d63ebbddff372a3cd4cd573513287e3524b5a5415f678970057a4b"}, diff --git a/Gemfile.lock b/Gemfile.lock index 91ebcfe15a4..1df0af6510a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -874,7 +874,7 @@ GEM rest-client (~> 2.0) launchy (2.5.0) addressable (~> 2.7) - lefthook (1.3.3) + lefthook (1.3.5) letter_opener (1.7.0) launchy (~> 2.2) letter_opener_web (2.0.0) @@ -1111,7 +1111,7 @@ GEM tty-color (~> 0.5) peek (1.1.0) railties (>= 4.0.0) - pg (1.4.5) + pg (1.4.6) pg_query (2.2.1) google-protobuf (>= 3.19.2) plist (3.6.0) @@ -1151,7 +1151,7 @@ GEM pyu-ruby-sasl (0.0.3.3) raabro (1.4.0) racc (1.6.2) - rack (2.2.6.2) + rack (2.2.6.4) rack-accept (0.4.5) rack (>= 0.4) rack-attack (6.6.1) @@ -1777,7 +1777,7 @@ DEPENDENCIES knapsack (~> 1.21.1) kramdown (~> 2.3.1) kubeclient (~> 4.11.0) - lefthook (~> 1.3.3) + lefthook (~> 1.3.5) letter_opener_web (~> 2.0.0) license_finder (~> 7.0) licensee (~> 9.15) @@ -1829,7 +1829,7 @@ DEPENDENCIES parallel (~> 1.19) parslet (~> 1.8) peek (~> 1.1) - pg (~> 1.4.5) + pg (~> 1.4.6) pg_query (~> 2.2, >= 2.2.1) png_quantizator (~> 0.2.1) premailer-rails (~> 1.10.3) @@ -1839,7 +1839,7 @@ DEPENDENCIES pry-shell (~> 0.6.1) puma (~> 5.6.5) puma_worker_killer (~> 0.3.1) - rack (~> 2.2.6, >= 2.2.6.2) + rack (~> 2.2.6, >= 2.2.6.4) rack-attack (~> 6.6.1) rack-cors (~> 1.1.1) rack-oauth2 (~> 1.21.3) diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 6a7ce4f1c41..301dd1c5669 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -204,7 +204,11 @@ export default class Shortcuts { } static focusSearch(e) { - $('#search').focus(); + if (gon.use_new_navigation) { + document.querySelector('#super-sidebar-search')?.click(); + } else { + document.querySelector('#search')?.focus(); + } if (e.preventDefault) { e.preventDefault(); diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 9cb96283689..5a002784937 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -135,6 +135,8 @@ function initNewNavToggle() { }); } -requestIdleCallback(initStatusTriggers); +if (!gon?.use_new_navigation) { + requestIdleCallback(initStatusTriggers); +} requestIdleCallback(initNavUserDropdownTracking); requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 7da3bab0a4b..520d7f627f6 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -1,3 +1,6 @@ +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { __ } from '~/locale'; + const commonTooltips = () => ({ mode: 'x', intersect: false, @@ -98,3 +101,38 @@ export const firstAndLastY = (data) => { return [firstY, lastY]; }; + +const toolboxIconSvgPath = async (name) => { + return `path://${await getSvgIconPathContent(name)}`; +}; + +export const getToolboxOptions = async () => { + const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath); + + try { + const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises); + + return { + toolbox: { + feature: { + dataZoom: { + icon: { zoom: marqueeSelectionPath, back: redoPath }, + }, + restore: { + icon: repeatPath, + }, + saveAsImage: { + icon: downloadPath, + }, + }, + }, + }; + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(__('SVG could not be rendered correctly: '), e); + } + + return {}; + } +}; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index bd47f10b3ac..7cfcd11ece9 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,3 +1,7 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; export const BACKSPACE_KEY = 'Backspace'; +export const ARROW_DOWN_KEY = 'ArrowDown'; +export const ARROW_UP_KEY = 'ArrowUp'; +export const END_KEY = 'End'; +export const HOME_KEY = 'Home'; diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js new file mode 100644 index 00000000000..e6679323563 --- /dev/null +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -0,0 +1,40 @@ +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { __ } from '~/locale'; + +export const i18n = { + defaultPrompt: __('This comment appears to have a token in it. Are you sure you want to add it?'), + primaryBtnText: __('Proceed'), +}; + +const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: 'glpat-[0-9a-zA-Z_-]{20}', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=[0-9a-zA-Z_-]{20}', + }, +]; + +export const containsSensitiveToken = (message) => { + for (const rule of sensitiveDataPatterns) { + const regex = new RegExp(rule.regex, 'gi'); + if (regex.test(message)) { + return true; + } + } + return false; +}; + +export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) { + const confirmed = await confirmAction(prompt, { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }); + if (!confirmed) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4bcddb260e1..d06358aaef4 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,6 +7,7 @@ import { createAlert } from '~/alert'; import { badgeState } from '~/issuable/components/status_box.vue'; import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; +import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -224,7 +225,7 @@ export default { handleSaveDraft() { this.handleSave({ isDraft: true }); }, - handleSave({ withIssueAction = false, isDraft = false } = {}) { + async handleSave({ withIssueAction = false, isDraft = false } = {}) { this.errors = []; if (this.note.length) { @@ -246,6 +247,13 @@ export default { noteData.data.note.type = constants.DISCUSSION_NOTE; } + if (containsSensitiveToken(this.note)) { + const confirmed = await confirmSensitiveAction(); + if (!confirmed) { + return; + } + } + this.note = ''; // Empty textarea while being requested. Repopulate in catch this.stopPolling(); diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 19e72da65f2..dfae43bf19c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -81,7 +81,7 @@ export default { }, }, apollo: { - currentAttribute: { + issuable: { query() { const { current } = this.issuableAttributeQuery; const { query } = current[this.issuableType]; @@ -95,11 +95,12 @@ export default { }; }, update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; } - - return data?.workspace?.issuable.attribute; }, error(error) { createAlert({ @@ -108,13 +109,26 @@ export default { error, }); }, + subscribeToMore: { + document() { + return issuableAttributesQueries[this.issuableAttribute].subscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return this.shouldSkipRealTimeEpicLinkUpdates; + }, + }, }, }, data() { return { updating: false, selectedTitle: null, - currentAttribute: null, + issuable: {}, hasCurrentAttribute: false, editConfirmation: false, tracking: { @@ -125,6 +139,12 @@ export default { }; }, computed: { + currentAttribute() { + return this.issuable.attribute; + }, + issuableId() { + return this.issuable.id; + }, issuableAttributeQuery() { return this.issuableAttributesQueries[this.issuableAttribute]; }, @@ -135,7 +155,7 @@ export default { return this.currentAttribute?.webUrl; }, loading() { - return this.$apollo.queries.currentAttribute.loading; + return this.$apollo.queries.issuable.loading; }, attributeTypeTitle() { return this.widgetTitleText[this.issuableAttribute]; @@ -170,6 +190,13 @@ export default { ? !this.editConfirmation : false; }, + shouldSkipRealTimeEpicLinkUpdates() { + return ( + !this.issuableId || + this.issuableAttribute !== IssuableAttributeType.Epic || + !this.glFeatures?.realTimeIssueEpicLinks + ); + }, }, methods: { updateAttribute({ id }) { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index 6798607b954..e8a54b0515e 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -6,73 +6,64 @@ import { GlToken, GlTooltipDirective, GlResizeObserverDirective, + GlModal, } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import { debounce } from 'lodash'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { debounce, clamp } from 'lodash'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; -import Tracking from '~/tracking'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; import { + MIN_SEARCH_TERM, SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, } from '~/vue_shared/global_search/constants'; import { - FIRST_DROPDOWN_INDEX, - SEARCH_BOX_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, IS_SEARCHING, - IS_FOCUSED, - IS_NOT_FOCUSED, + SEARCH_MODAL_ID, + SEARCH_INPUT_SELECTOR, + SEARCH_RESULTS_ITEM_SELECTOR, } from '../constants'; -import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from './global_search_default_items.vue'; -import HeaderSearchScopedItems from './global_search_scoped_items.vue'; +import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from './global_search_default_items.vue'; +import GlobalSearchScopedItems from './global_search_scoped_items.vue'; export default { - name: 'HeaderSearchApp', + name: 'GlobalSearchModal', + SEARCH_MODAL_ID, i18n: { SEARCH_GITLAB, - SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN, - SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN, + SEARCH_DESCRIBED_BY_WITH_RESULTS, SEARCH_DESCRIBED_BY_DEFAULT, SEARCH_DESCRIBED_BY_UPDATED, SEARCH_RESULTS_LOADING, SEARCH_RESULTS_SCOPE, - KBD_HELP, + MIN_SEARCH_TERM, }, directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, - HeaderSearchDefaultItems, - HeaderSearchScopedItems, - HeaderSearchAutocompleteItems, - DropdownKeyboardNavigation, + GlobalSearchDefaultItems, + GlobalSearchScopedItems, + GlobalSearchAutocompleteItems, GlIcon, GlToken, - }, - data() { - return { - showDropdown: false, - isFocused: false, - currentFocusIndex: SEARCH_BOX_INDEX, - }; + GlModal, }, computed: { ...mapState(['search', 'loading', 'searchContext']), - ...mapGetters(['searchQuery', 'searchOptions']), + ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), searchText: { get() { return this.search; @@ -81,51 +72,26 @@ export default { this.setSearch(value); }, }, - currentFocusedOption() { - return this.searchOptions[this.currentFocusIndex]; - }, - currentFocusedId() { - return this.currentFocusedOption?.html_id; - }, - isLoggedIn() { - return Boolean(gon?.current_username); - }, - showSearchDropdown() { - if (!this.showDropdown || !this.isLoggedIn) { - return false; - } - return this.searchOptions?.length > 0; - }, showDefaultItems() { return !this.searchText; }, searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, - defaultIndex() { - if (this.showDefaultItems) { - return SEARCH_BOX_INDEX; - } - return FIRST_DROPDOWN_INDEX; - }, - - searchInputDescribeBy() { - if (this.isLoggedIn) { - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN; - } - return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN; + showScopedSearchItems() { + return this.searchTermOverMin && this.scopedSearchOptions.length > 1; }, - dropdownResultsDescription() { - if (!this.showSearchDropdown) { - return ''; // This allows aria-live to see register an update when the dropdown is shown - } - + searchResultsDescription() { if (this.showDefaultItems) { return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { count: this.searchOptions.length, }); } + if (!this.searchTermOverMin) { + return this.$options.i18n.MIN_SEARCH_TERM; + } + return this.loading ? this.$options.i18n.SEARCH_RESULTS_LOADING : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { @@ -135,12 +101,10 @@ export default { searchBarClasses() { return { [IS_SEARCHING]: this.searchTermOverMin, - [IS_FOCUSED]: this.isFocused, - [IS_NOT_FOCUSED]: !this.isFocused, }; }, showScopeHelp() { - return this.searchTermOverMin && this.isFocused; + return this.searchTermOverMin; }, searchBarItem() { return this.searchOptions?.[0]; @@ -159,47 +123,7 @@ export default { }, methods: { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), - openDropdown() { - this.showDropdown = true; - - // check isFocused state to avoid firing duplicate events - if (!this.isFocused) { - this.isFocused = true; - this.$emit('expandSearchBar', true); - - Tracking.event(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - } - }, - closeDropdown() { - this.showDropdown = false; - }, - collapseAndCloseSearchBar() { - // we need a delay on this method - // for the search bar not to remove - // the clear button from dom - // and register clicks on dropdown items - setTimeout(() => { - this.showDropdown = false; - this.isFocused = false; - this.$emit('collapseSearchBar'); - - Tracking.event(undefined, 'blur_input', { - label: 'global_search', - property: 'navigation_top', - }); - }, 200); - }, - submitSearch() { - if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { - return null; - } - return visitUrl(this.currentFocusedOption?.url || this.searchQuery); - }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { - this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -216,105 +140,174 @@ export default { } inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; }, + getFocusableOptions() { + return Array.from( + this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [], + ); + }, + onKeydown(event) { + const { code, target } = event; + + let stop = true; + + const elements = this.getFocusableOptions(); + if (elements.length < 1) return; + + const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + + if (code === HOME_KEY) { + this.focusItem(0, elements); + } else if (code === END_KEY) { + this.focusItem(elements.length - 1, elements); + } else if (code === ARROW_UP_KEY) { + if (isSearchInput) return; + + if (elements.indexOf(target) === 0) { + this.focusSearchInput(); + return; + } + this.focusNextItem(event, elements, -1); + } else if (code === ARROW_DOWN_KEY) { + this.focusNextItem(event, elements, 1); + } else if (code === ESC_KEY) { + this.$refs.searchModal.close(); + } else { + stop = false; + } + + if (stop) { + event.preventDefault(); + } + }, + focusSearchInput() { + this.$refs.searchInputBox.$el.querySelector('input').focus(); + }, + focusNextItem(event, elements, offset) { + const { target } = event; + const currentIndex = elements.indexOf(target); + const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); + + this.focusItem(nextIndex, elements); + }, + focusItem(index, elements) { + this.nextFocusedItemIndex = index; + + elements[index]?.focus(); + }, + submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return; + } + visitUrl(this.searchQuery); + }, }, - SEARCH_BOX_INDEX, - FIRST_DROPDOWN_INDEX, SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <form - v-outside="closeDropdown" - role="search" - :aria-label="$options.i18n.SEARCH_GITLAB" - class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarClasses" - data-testid="header-search-form" + <gl-modal + ref="searchModal" + :modal-id="$options.SEARCH_MODAL_ID" + hide-header + hide-footer + hide-header-close + scrollable + body-class="gl-p-0!" + modal-class="global-search-modal" + :centered="false" > - <gl-search-box-by-type - id="search" - ref="searchInputBox" - v-model="searchText" - role="searchbox" - class="gl-z-index-1" - data-qa-selector="search_term_field" - autocomplete="off" - :placeholder="$options.i18n.SEARCH_GITLAB" - :aria-activedescendant="currentFocusedId" - :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" - @focus="openDropdown" - @click="openDropdown" - @blur="collapseAndCloseSearchBar" - @input="getAutocompleteOptions" - @keydown.enter.stop.prevent="submitSearch" - @keydown.esc.stop.prevent="closeDropdown" - /> - <gl-token - v-if="showScopeHelp" - v-gl-resize-observer-directive="observeTokenWidth" - class="in-search-scope-help" - :view-only="true" - :title="scopeTokenTitle" - ><gl-icon - v-if="infieldHelpIcon" - class="gl-mr-2" - :aria-label="infieldHelpContent" - :name="infieldHelpIcon" - :size="16" - />{{ - getTruncatedScope( - sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { - scope: infieldHelpContent, - }), - ) - }} - </gl-token> - <kbd - v-show="!isFocused" - v-gl-tooltip.bottom.hover.html - class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" - :title="$options.i18n.KBD_HELP" - >/</kbd - > - <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ - searchInputDescribeBy - }}</span> - <span - role="region" - :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" - class="gl-sr-only" - aria-live="polite" - aria-atomic="true" + <form + role="search" + :aria-label="$options.i18n.SEARCH_GITLAB" + class="gl-relative gl-rounded-base gl-w-full" + :class="searchBarClasses" + data-testid="global-search-form" > - {{ dropdownResultsDescription }} - </span> - <div - v-if="showSearchDropdown" - data-testid="header-search-dropdown-menu" - class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3" - > - <div class="header-search-dropdown-content gl-py-2"> - <dropdown-keyboard-navigation - v-model="currentFocusIndex" - :max="searchOptions.length - 1" - :min="$options.FIRST_DROPDOWN_INDEX" - :default-index="defaultIndex" - @tab="closeDropdown" - /> - <header-search-default-items - v-if="showDefaultItems" - :current-focused-option="currentFocusedOption" + <div class="gl-p-1"> + <gl-search-box-by-type + id="search" + ref="searchInputBox" + v-model="searchText" + role="searchbox" + data-testid="global-search-input" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_GITLAB" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + borderless + @input="getAutocompleteOptions" + @keydown.enter.stop.prevent="submitSearch" + @keydown="onKeydown" /> - <template v-else> - <header-search-scoped-items - v-if="searchTermOverMin" - :current-focused-option="currentFocusedOption" + <gl-token + v-if="showScopeHelp" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help gl-sm-display-block gl-display-none" + view-only + :title="scopeTokenTitle" + > + <gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" /> - <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> + {{ + getTruncatedScope( + sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), + ) + }} + </gl-token> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> + {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} + </span> + </div> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ searchResultsDescription }} + </span> + <div + ref="resultsList" + data-testid="global-search-results" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + @keydown="onKeydown" + > + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> </template> </div> - </div> - </form> + + <template v-if="searchContext"> + <input + v-if="searchContext.group" + type="hidden" + name="group_id" + :value="searchContext.group.id" + /> + <input + v-if="searchContext.project" + type="hidden" + name="project_id" + :value="searchContext.project.id" + /> + + <template v-if="searchContext.group || searchContext.project"> + <input type="hidden" name="scope" :value="searchContext.scope" /> + <input type="hidden" name="search_code" :value="searchContext.code_search" /> + </template> + + <input type="hidden" name="snippets" :value="searchContext.for_snippets" /> + <input type="hidden" name="repository_ref" :value="searchContext.ref" /> + </template> + </form> + </gl-modal> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index 1838214def6..cd623200b03 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -1,113 +1,36 @@ <script> -import { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, - GlAvatar, - GlAlert, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { truncateNamespace } from '~/lib/utils/text_utility'; -import { - GROUPS_CATEGORY, - PROJECTS_CATEGORY, - MERGE_REQUEST_CATEGORY, - ISSUES_CATEGORY, - RECENT_EPICS_CATEGORY, - AUTOCOMPLETE_ERROR_MESSAGE, -} from '~/vue_shared/global_search/constants'; -import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants'; +import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchAutocompleteItems', + name: 'GlobalSearchAutocompleteItems', i18n: { AUTOCOMPLETE_ERROR_MESSAGE, }, components: { - GlDropdownItem, - GlDropdownSectionHeader, - GlDropdownDivider, GlAvatar, GlAlert, GlLoadingIcon, + GlDisclosureDropdownGroup, }, directives: { SafeHtml, }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, - }, computed: { - ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']), - ...mapGetters(['autocompleteGroupedSearchOptions']), - }, - watch: { - currentFocusedOption() { - const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; - - if (focusedElement) { - focusedElement.scrollIntoView(false); - } + ...mapState(['search', 'loading', 'autocompleteError']), + ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), + isPrecededByScopedOptions() { + return this.scopedSearchOptions.length > 1; }, }, methods: { - truncateNamespace(string) { - if (string.split(' / ').length > 2) { - return truncateNamespace(string); - } - - return string; - }, highlightedName(val) { return highlight(val, this.search); }, - avatarSize(data) { - if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) { - return LARGE_AVATAR_PX; - } - - return SMALL_AVATAR_PX; - }, - isOptionFocused(data) { - return this.currentFocusedOption?.html_id === data.html_id; - }, - isProjectsCategory(data) { - return data.category === PROJECTS_CATEGORY; - }, - getEntityId(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_id || data.id || this.searchContext?.group?.id; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_id || data.id || this.searchContext?.project?.id; - default: - return data.id; - } - }, - getEntitytName(data) { - switch (data.category) { - case GROUPS_CATEGORY: - case RECENT_EPICS_CATEGORY: - return data.group_name || data.value || data.label || this.searchContext?.group?.name; - case PROJECTS_CATEGORY: - case ISSUES_CATEGORY: - case MERGE_REQUEST_CATEGORY: - return data.project_name || data.value || data.label || this.searchContext?.project?.name; - default: - return data.label; - } - }, }, AVATAR_SHAPE_OPTION_RECT, }; @@ -115,46 +38,46 @@ export default { <template> <div> - <template v-if="!loading"> - <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category"> - <gl-dropdown-divider v-if="index > 0" /> - <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="data in option.data" - :id="data.html_id" - :ref="data.html_id" - :key="data.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" - :aria-selected="isOptionFocused(data)" - :aria-label="data.label" - tabindex="-1" - :href="data.url" - > - <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> + <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> + <gl-disclosure-dropdown-group + v-for="group in autocompleteGroupedSearchOptions" + :key="group.name" + :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :group="group" + bordered + > + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> <gl-avatar - v-if="data.avatar_url !== undefined" - :src="data.avatar_url" - :entity-id="getEntityId(data)" - :entity-name="getEntitytName(data)" - :size="avatarSize(data)" + v-if="item.avatar_url !== undefined" + class="gl-mr-3" + :src="item.avatar_url" + :entity-id="item.entity_id" + :entity-name="item.entity_name" + :size="item.avatar_size" :shape="$options.AVATAR_SHAPE_OPTION_RECT" + aria-hidden="true" /> <span class="gl-display-flex gl-flex-direction-column"> <span - v-safe-html="highlightedName(data.value || data.label)" + v-safe-html="highlightedName(item.text)" class="gl-text-gray-900" + data-testid="autocomplete-item-name" ></span> <span - v-if="data.value" - v-safe-html="truncateNamespace(data.label)" + v-if="item.value" + v-safe-html="item.namespace" class="gl-font-sm gl-text-gray-500" + data-testid="autocomplete-item-namespace" ></span> </span> </div> - </gl-dropdown-item> - </div> - </template> + </template> + </gl-disclosure-dropdown-group> + </ul> + <gl-loading-icon v-else size="lg" class="my-4" /> + <gl-alert v-if="autocompleteError" class="gl-text-body gl-mt-2" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue index f0d398297e9..239c61fd750 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -1,23 +1,15 @@ <script> -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; export default { - name: 'HeaderSearchDefaultItems', + name: 'GlobalSearchDefaultItems', i18n: { ALL_GITLAB, }, components: { - GlDropdownSectionHeader, - GlDropdownItem, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['searchContext']), @@ -29,30 +21,18 @@ export default { this.$options.i18n.ALL_GITLAB ); }, - }, - methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; + defaultItemsGroup() { + return { + name: this.sectionHeader, + items: this.defaultSearchOptions, + }; }, }, }; </script> <template> - <div> - <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="option in defaultSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="option.title" - tabindex="-1" - :href="option.url" - > - <span aria-hidden="true">{{ option.title }}</span> - </gl-dropdown-item> - </div> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" /> + </ul> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue index 1ef88492b23..76600f829f6 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue @@ -1,47 +1,26 @@ <script> -import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui'; +import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { truncate } from '~/lib/utils/text_utility'; -import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants'; import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; export default { - name: 'HeaderSearchScopedItems', - i18n: { - SCOPED_SEARCH_ITEM_ARIA_LABEL, - }, + name: 'GlobalSearchScopedItems', components: { - GlDropdownItem, GlIcon, GlToken, - }, - props: { - currentFocusedOption: { - type: Object, - required: false, - default: () => null, - }, + GlDisclosureDropdownGroup, }, computed: { ...mapState(['search']), - ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']), + ...mapGetters(['scopedSearchGroup']), }, methods: { - isOptionFocused(option) { - return this.currentFocusedOption?.html_id === option.html_id; - }, - ariaLabel(option) { - return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, { - search: this.search, - description: option.description || option.icon, - scope: option.scope || '', - }); - }, - titleLabel(option) { + titleLabel(item) { return sprintf(s__('GlobalSearch|in %{scope}'), { search: this.search, - scope: option.scope || option.description, + scope: item.scope || item.description, }); }, getTruncatedScope(scope) { @@ -53,35 +32,23 @@ export default { <template> <div> - <gl-dropdown-item - v-for="option in scopedSearchOptions" - :id="option.html_id" - :ref="option.html_id" - :key="option.html_id" - class="gl-max-w-full" - :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" - :aria-selected="isOptionFocused(option)" - :aria-label="ariaLabel(option)" - tabindex="-1" - :href="option.url" - :title="titleLabel(option)" - > - <span - ref="token-text-content" - class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full" - > - <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" /> - <span class="gl-flex-grow-1 gl-relative"> - <gl-token - class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!" - :view-only="true" + <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!"> + <template #list-item="{ item }"> + <span + class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full" > - <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" /> - <span>{{ getTruncatedScope(titleLabel(option)) }}</span> - </gl-token> - {{ search }} - </span> - </span> - </gl-dropdown-item> + <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" /> + <span class="gl-flex-grow-1"> + <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(item)) }}</span> + </gl-token> + {{ search }} + </span> + </span> + </template> + </gl-disclosure-dropdown-group> + </ul> </div> </template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js index b9bb4e573fd..cb267df6122 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; -export const FIRST_DROPDOWN_INDEX = 0; - -export const SEARCH_BOX_INDEX = -1; - export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; @@ -20,14 +16,13 @@ export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; export const SCOPE_TOKEN_MAX_LENGTH = 36; -export const INPUT_FIELD_PADDING = 52; - -export const HEADER_INIT_EVENTS = ['input', 'focus']; +export const INPUT_FIELD_PADDING = 84; export const IS_SEARCHING = 'is-searching'; -export const IS_FOCUSED = 'is-focused'; -export const IS_NOT_FOCUSED = 'is-not-focused'; export const FETCH_TYPES = ['generic', 'search']; +export const SEARCH_MODAL_ID = 'super-sidebar-search-modal'; + +export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless'; -export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px'; +export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js index f86463b94d1..4a42f416206 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -1,6 +1,5 @@ import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; - import { MSG_ISSUES_ASSIGNED_TO_ME, MSG_ISSUES_IVE_CREATED, @@ -10,8 +9,10 @@ import { MSG_IN_ALL_GITLAB, PROJECTS_CATEGORY, GROUPS_CATEGORY, - DROPDOWN_ORDER, + SEARCH_RESULTS_ORDER, } from '~/vue_shared/global_search/constants'; +import { getFormattedItem } from '../utils'; + import { ICON_GROUP, ICON_SUBGROUP, @@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => { const issues = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${getters.scopedIssuesPath}/?author_username=${userName}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, ]; const mergeRequests = [ { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${getters.scopedMRPath}/?assignee_username=${userName}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + text: MSG_MR_IM_REVIEWER, + href: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${getters.scopedMRPath}/?author_username=${userName}`, + text: MSG_MR_IVE_CREATED, + href: `${getters.scopedMRPath}/?author_username=${userName}`, }, ]; return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; @@ -145,58 +141,64 @@ export const allUrl = (state) => { }; export const scopedSearchOptions = (state, getters) => { - const options = []; + const items = []; if (state.searchContext?.project) { - options.push({ - html_id: 'scoped-in-project', + items.push({ + text: 'scoped-in-project', scope: state.searchContext.project?.name || '', scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: getters.projectUrl, + href: getters.projectUrl, }); } if (state.searchContext?.group) { - options.push({ - html_id: 'scoped-in-group', + items.push({ + text: 'scoped-in-group', scope: state.searchContext.group?.name || '', scopeCategory: GROUPS_CATEGORY, icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, - url: getters.groupUrl, + href: getters.groupUrl, }); } - options.push({ - html_id: 'scoped-in-all', + items.push({ + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: getters.allUrl, + href: getters.allUrl, }); - return options; + return items; +}; + +export const scopedSearchGroup = (state, getters) => { + const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : []; + return { items }; }; export const autocompleteGroupedSearchOptions = (state) => { const groupedOptions = {}; const results = []; - state.autocompleteOptions.forEach((option) => { - const category = groupedOptions[option.category]; + state.autocompleteOptions.forEach((item) => { + const group = groupedOptions[item.category]; + const formattedItem = getFormattedItem(item, state.searchContext); - if (category) { - category.data.push(option); + if (group) { + group.items.push(formattedItem); } else { - groupedOptions[option.category] = { - category: option.category, - data: [option], + groupedOptions[item.category] = { + name: formattedItem.category, + items: [formattedItem], }; - results.push(groupedOptions[option.category]); + results.push(groupedOptions[formattedItem.category]); } }); return results.sort( - (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category), + (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name), ); }; @@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => { } const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( - (options, group) => { - return [...options, ...group.data]; + (items, group) => { + return [...items, ...group.items]; }, [], ); @@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => { return sortedAutocompleteOptions; } - return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); + return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions); }; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js index 6e65345757f..d7d9ebecd16 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js @@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; - export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js index 19b4d4ec330..9936c3f59d8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js @@ -8,11 +8,7 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = [...state.autocompleteOptions].concat( - data.map((d, i) => { - return { html_id: `autocomplete-${d.category}-${i}`, ...d }; - }), - ); + state.autocompleteOptions = [...state.autocompleteOptions].concat(data); state.autocompleteError = false; }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js new file mode 100644 index 00000000000..11d1fa1ab95 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -0,0 +1,81 @@ +import { pickBy } from 'lodash'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; +import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; + +const getTruncatedNamespace = (string) => { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; +}; +const getAvatarSize = (category) => { + if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; +}; + +const getEntityId = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_id || item.id || searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_id || item.id || searchContext?.project?.id; + default: + return item.id; + } +}; +const getEntityName = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_name || item.value || item.label || searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_name || item.value || item.label || searchContext?.project?.name; + default: + return item.label; + } +}; + +export const getFormattedItem = (item, searchContext) => { + const { id, category, value, label, url: href, avatar_url } = item; + let namespace; + const text = value || label; + if (value) { + namespace = getTruncatedNamespace(label); + } + const avatarSize = getAvatarSize(category); + const entityId = getEntityId(item, searchContext); + const entityName = getEntityName(item, searchContext); + + return pickBy( + { + id, + category, + value, + label, + text, + href, + avatar_url, + avatar_size: avatarSize, + namespace, + entity_id: entityId, + entity_name: entityName, + }, + (val) => val !== undefined, + ); +}; diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index e27acb60372..2597d0518e6 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,6 +1,6 @@ <script> -import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import logo from '../../../../views/shared/_logo.svg'; import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; @@ -8,12 +8,14 @@ import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; import MergeRequestMenu from './merge_request_menu.vue'; import UserMenu from './user_menu.vue'; +import { SEARCH_MODAL_ID } from './global_search/constants'; export default { // "GitLab Next" is a proper noun, so don't translate "Next" /* eslint-disable-next-line @gitlab/require-i18n-strings */ NEXT_LABEL: 'Next', logo, + SEARCH_MODAL_ID, components: { Counter, CreateMenu, @@ -21,6 +23,10 @@ export default { GlButton, MergeRequestMenu, UserMenu, + SearchModal: () => + import( + /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' + ), }, i18n: { collapseSidebar: __('Collapse sidebar'), @@ -28,10 +34,16 @@ export default { issues: __('Issues'), mergeRequests: __('Merge requests'), search: __('Search'), + searchKbdHelp: sprintf( + s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), todoList: __('To-Do list'), }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, SafeHtml, }, inject: ['rootPath'], @@ -77,12 +89,18 @@ export default { @click="collapseSidebar" /> <create-menu :groups="sidebarData.create_new_menu_groups" /> + <gl-button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp" + v-gl-modal="$options.SEARCH_MODAL_ID" + data-testid="super-sidebar-search-button" icon="search" :aria-label="$options.i18n.search" category="tertiary" - href="/search" /> + <search-modal /> + <user-menu :data="sidebarData" /> </div> <div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2"> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 34bbb3ce177..de3f3241366 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -167,6 +167,9 @@ export default { this.trackEvents(); this.initCallout(); }, + closeDropdown() { + this.$refs.userDropdown.close(); + }, initCallout() { if (this.showNotificationDot) { PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el); @@ -189,6 +192,7 @@ export default { <template> <div> <gl-disclosure-dropdown + ref="userDropdown" placement="right" data-testid="user-dropdown" data-qa-selector="user_menu" @@ -220,6 +224,7 @@ export default { v-if="data.status.can_update" :item="statusItem" data-testid="status-item" + @action="closeDropdown" /> <gl-disclosure-dropdown-item diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 4395cc2f5f0..c5e8c68b940 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,7 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; +import createStore from './components/global_search/store'; import { bindSuperSidebarCollapsedEvents, initSuperSidebarCollapsedState, @@ -23,6 +25,10 @@ export const initSuperSidebar = () => { initSuperSidebarCollapsedState(); const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + const sidebarData = JSON.parse(sidebar); + const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; return new Vue({ el, @@ -32,10 +38,18 @@ export const initSuperSidebar = () => { rootPath, toggleNewNavEndpoint, }, + store: createStore({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search: '', + }), render(h) { return h(SuperSidebar, { props: { - sidebarData: JSON.parse(sidebar), + sidebarData, }, }); }, diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js index 388e7c92f03..4211b9578a2 100644 --- a/app/assets/javascripts/vue_shared/global_search/constants.js +++ b/app/assets/javascripts/vue_shared/global_search/constants.js @@ -27,6 +27,10 @@ export const KBD_HELP = sprintf( { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, false, ); +export const MIN_SEARCH_TERM = s__( + 'GlobalSearch|The search term must be at least 3 characters long.', +); + export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}'); export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index d119cdc2785..7657bf8567b 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,7 +1,8 @@ <script> -import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; @@ -24,6 +25,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLabel, GlLink, GlButton, GlIcon, @@ -71,6 +73,12 @@ export default { }; }, computed: { + labels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; + }, + allowsScopedLabels() { + return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; + }, canHaveChildren() { return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; }, @@ -166,6 +174,9 @@ export default { this.isLoadingChildren = false; } }, + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, }, }; </script> @@ -190,66 +201,72 @@ export default { @click="toggleItem" /> <div - class="work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-rounded-base" - :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']" + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base" data-testid="links-child" > - <span - :id="`stateIcon-${childItem.id}`" - class="gl-cursor-help gl-mr-3 gl-line-height-32" - :class="{ 'gl-display-flex': hasMetadata }" - data-testid="item-status-icon" - > - <gl-icon - class="gl-text-secondary" - :class="iconClass" - :name="iconName" - :aria-label="stateTimestampTypeText" - /> - </span> - <div - class="gl-display-flex gl-flex-grow-1" - :class="{ - 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, - 'gl-align-items-center': !hasMetadata, - }" - > - <div class="gl-display-flex"> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" + <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0" + > + <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0"> + <span + :id="`stateIcon-${childItem.id}`" + class="gl-cursor-help" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <span v-if="childItem.confidential"> + <gl-icon + v-gl-tooltip.top + name="eye-slash" + class="gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + </span> + <gl-link + :href="childPath" + class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :metadata-widgets="metadataWidgets" + class="gl-ml-6 ml-xl-0" /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" + </div> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" /> - <gl-link - :href="childPath" - class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" - data-testid="item-title" - @click="$emit('click', $event)" - @mouseover="$emit('mouseover')" - @mouseout="$emit('mouseout')" - > - {{ childItem.title }} - </gl-link> </div> - <work-item-link-child-metadata - v-if="hasMetadata" - :metadata-widgets="metadataWidgets" - class="gl-mt-1" - /> </div> - <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" - > + <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> <work-item-links-menu :work-item-id="childItem.id" :parent-work-item-id="issuableGid" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue index 80802cb3858..ddeac2b92ae 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -1,16 +1,14 @@ <script> -import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import { isScopedLabel } from '~/lib/utils/common_utils'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; -import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants'; export default { components: { - GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, @@ -33,12 +31,6 @@ export default { assignees() { return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || []; }, - labels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || []; - }, - allowsScopedLabels() { - return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels; - }, assigneesCollapsedTooltip() { if (this.assignees.length > 2) { return sprintf(s__('WorkItem|%{count} more assignees'), { @@ -56,21 +48,16 @@ export default { return ''; }, }, - methods: { - showScopedLabel(label) { - return isScopedLabel(label) && this.allowsScopedLabels; - }, - }, }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <div class="gl-display-flex gl-md-justify-content-end gl-gap-3"> <slot></slot> <item-milestone v-if="milestone" :milestone="milestone" - class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" /> <gl-avatars-inline v-if="assignees.length" @@ -81,7 +68,6 @@ export default { badge-tooltip-prop="name" :badge-sr-only-text="assigneesCollapsedTooltip" :class="assigneesContainerClass" - class="gl-mr-5" > <template #avatar="{ avatar }"> <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> @@ -89,18 +75,6 @@ export default { </gl-avatar-link> </template> </gl-avatars-inline> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap"> - <gl-label - v-for="label in labels" - :key="label.id" - :title="label.title" - :background-color="label.color" - :description="label.description" - :scoped="showScopedLabel(label)" - class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm" - tooltip-placement="top" - /> - </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index 97eaf2c0422..b72de98199e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -186,7 +186,7 @@ export default { </template> <template #body> <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty"> - <p class="gl-mb-3"> + <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500"> {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} </p> </div> diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index bf447d417e6..bd0400cdaa3 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -220,3 +220,38 @@ } } } + +.global-search-modal { + padding: 3rem 0.5rem 0; + + &.gl-modal .modal-dialog { + align-items: flex-start; + } + + @include gl-media-breakpoint-up(sm) { + padding: 5rem 1rem 0; + } + + // This is a temporary workaround! + // the button in GitLab UI Search components need to be updated to not be the small size + // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540 + .gl-search-box-by-type-clear.btn-sm { + padding: 0.5rem !important; + } + + .is-searching { + .in-search-scope-help { + position: absolute; + top: 0.625rem; + right: 2.5rem; + } + } + + .gl-search-box-by-type-input-borderless { + @include gl-rounded-base; + } + + .global-search-results { + max-height: 30rem; + } +} diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 00c86c46ac8..5f6883623b2 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -87,19 +87,6 @@ } } -.work-item-link-child { - @include gl-border-1; - @include gl-border-solid; - @include gl-border-transparent; - @include gl-rounded-base; - - &:hover, - &:focus-within { - @include gl-bg-white; - @include gl-border-gray-50; - } -} - // sticky error placement for errors in modals , by default it is 83px for full view #work-item-detail-modal { .flash-container.flash-container-page.sticky { diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 7e1ba49d442..d33d3b046e3 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -53,6 +53,8 @@ module Integrations :issues_events, :issues_url, :jenkins_url, + :jira_issue_prefix, + :jira_issue_regex, :jira_issue_transition_automatic, :jira_issue_transition_id, :manual_configuration, diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb index ef58ab1972b..c66bf7c9e8c 100644 --- a/app/controllers/concerns/kas_cookie.rb +++ b/app/controllers/concerns/kas_cookie.rb @@ -3,6 +3,18 @@ module KasCookie extend ActiveSupport::Concern + included do + content_security_policy_with_context do |p| + next unless ::Gitlab::Kas::UserAccess.enabled? + + kas_url = ::Gitlab::Kas.tunnel_url + next if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception + + kas_url += '/' unless kas_url.end_with?('/') + p.connect_src(*Array.wrap(p.directives['connect-src']), kas_url) + end + end + def set_kas_cookie return unless ::Gitlab::Kas::UserAccess.enabled? diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index 95deacdc5b9..80c65948fff 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -14,3 +14,5 @@ class Dashboard::ApplicationController < ApplicationController @projects ||= current_user.authorized_projects.sorted_by_updated_desc.non_archived end end + +Dashboard::ApplicationController.prepend_mod diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a204023e34d..0851d2ef3e2 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -264,6 +264,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo status = merge! + Gitlab::ApplicationContext.push(merge_action_status: status.to_s) + if @merge_request.merge_error render json: { status: status, merge_error: @merge_request.merge_error } else diff --git a/app/graphql/resolvers/data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer_resolver.rb index 1a240d2811f..ed97de0a256 100644 --- a/app/graphql/resolvers/data_transfer_resolver.rb +++ b/app/graphql/resolvers/data_transfer_resolver.rb @@ -38,16 +38,18 @@ module Resolvers def resolve(**_args) return unless Feature.enabled?(:data_transfer_monitoring) + # TODO: This is mock data as this feature is in development + # Follow this epic for recent progress: https://gitlab.com/groups/gitlab-org/-/epics/9330 start_date = Date.new(2023, 0o1, 0o1) date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } - nodes = 0.upto(3).map do |i| + nodes = 0.upto(11).map do |i| { date: date_for_index.call(i), - repository_egress: 250_000, - artifacts_egress: 250_000, - packages_egress: 250_000, - registry_egress: 250_000 + repository_egress: rand(70000..550000), + artifacts_egress: rand(70000..550000), + packages_egress: rand(70000..550000), + registry_egress: rand(70000..550000) } end diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 1efbd4acdd9..56f4187ae42 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -81,7 +81,8 @@ module SidebarsHelper gitlab_com_and_canary: Gitlab.com_and_canary?, canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url, current_context: super_sidebar_current_context(project: project, group: group), - context_switcher_links: context_switcher_links + context_switcher_links: context_switcher_links, + search: search_data } end @@ -112,6 +113,16 @@ module SidebarsHelper private + def search_data + { + search_path: search_path, + issues_path: issues_dashboard_path, + mr_path: merge_requests_dashboard_path, + autocomplete_path: search_autocomplete_path, + search_context: header_search_context + } + end + def user_status_menu_data(user) { can_update: can?(user, :update_user_status, user), diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index b05beb6c764..e68574c5fca 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -29,7 +29,7 @@ module Mentionable def self.external_pattern strong_memoize(:external_pattern) do - issue_pattern = Integrations::BaseIssueTracker.reference_pattern + issue_pattern = Integrations::BaseIssueTracker.base_reference_pattern link_patterns = URI::DEFAULT_PARSER.make_regexp(%w(http https)) reference_pattern(link_patterns, issue_pattern) end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index e0994305e9d..7a54d354007 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -14,7 +14,7 @@ module Integrations # This pattern does not support cross-project references # The other code assumes that this pattern is a superset of all # overridden patterns. See ReferenceRegexes.external_pattern - def self.reference_pattern(only_long: false) + def self.base_reference_pattern(only_long: false) if only_long /(\b[A-Z][A-Z0-9_]*-)#{Gitlab::Regex.issue}/ else @@ -22,6 +22,10 @@ module Integrations end end + def reference_pattern(only_long: false) + self.class.base_reference_pattern(only_long: only_long) + end + def handle_properties # this has been moved from initialize_properties and should be improved # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb index 1b86ef73c85..003c896704a 100644 --- a/app/models/integrations/ewm.rb +++ b/app/models/integrations/ewm.rb @@ -6,7 +6,7 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def self.reference_pattern(only_long: true) + def reference_pattern(only_long: true) @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index a1cdd55ceae..aa3730d9559 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -23,6 +23,8 @@ module Integrations validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? validates :password, presence: true, if: :activated? + validates :jira_issue_prefix, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? + validates :jira_issue_regex, untrusted_regexp: true, length: { maximum: 255 }, if: :activated? validates :jira_issue_transition_id, format: { @@ -72,6 +74,18 @@ module Integrations non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') }, help: -> { s_('JiraService|Password for the server version or an API token for the cloud version') } + field :jira_issue_regex, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue regex') }, + help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') } + + field :jira_issue_prefix, + section: SECTION_TYPE_CONFIGURATION, + required: false, + title: -> { s_('JiraService|Jira issue prefix') }, + help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') } + field :jira_issue_transition_id, api_only: true # TODO: we can probably just delegate as part of @@ -90,8 +104,8 @@ module Integrations end # {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1 - def self.reference_pattern(only_long: true) - @reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/ + def reference_pattern(only_long: true) + @reference_pattern ||= jira_issue_match_regex end def self.valid_jira_cloud_url?(url) @@ -166,6 +180,11 @@ module Integrations type: SECTION_TYPE_JIRA_TRIGGER, title: _('Trigger'), description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.') + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: _('Jira issue matching'), + description: s_('Configure custom rules for Jira issue key matching') } ] @@ -325,6 +344,12 @@ module Integrations private + def jira_issue_match_regex + match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex) + + /\b#{jira_issue_prefix}(?<issue>#{match_regex})/ + end + def parse_project_from_issue_key(issue_key) issue_key.gsub(Gitlab::Regex.jira_issue_key_project_key_extraction_regex, '') end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index fa719f925ed..15246a37aa7 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -7,12 +7,11 @@ module Integrations validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 - def self.reference_pattern(only_long: false) - if only_long - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/ - else - /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})/ - end + def reference_pattern(only_long: false) + return @reference_pattern if defined?(@reference_pattern) + + regex_suffix = "|(#{Issue.reference_prefix}#{Gitlab::Regex.issue})" + @reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/ end def title diff --git a/app/models/issue.rb b/app/models/issue.rb index b7290fa1842..160894a0c4f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -57,11 +57,10 @@ class Issue < ApplicationRecord belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' - belongs_to :iteration, foreign_key: 'sprint_id' belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :work_items - belongs_to :moved_to, class_name: 'Issue' - has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id + belongs_to :moved_to, class_name: 'Issue', inverse_of: :moved_from + has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id, inverse_of: :moved_to has_internal_id :iid, scope: :namespace, track_if: -> { !importing? }, init: ->(issue, scope) do # we need this init for the case where the IID allocation in internal_ids#last_value diff --git a/app/models/iteration.rb b/app/models/iteration.rb deleted file mode 100644 index ebec24731ed..00000000000 --- a/app/models/iteration.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Placeholder class for model that is implemented in EE -class Iteration < ApplicationRecord - include IgnorableColumns - - self.table_name = 'sprints' - - def self.reference_prefix - '*iteration:' - end - - def self.reference_pattern - nil - end -end - -Iteration.prepend_mod_with('Iteration') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 85e95a556a8..c1511ee1233 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -44,7 +44,6 @@ class MergeRequest < ApplicationRecord belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" - belongs_to :iteration, foreign_key: 'sprint_id' has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(mr, scope) do diff --git a/app/models/project.rb b/app/models/project.rb index 8a8e4848eb1..03aa131e71b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1625,7 +1625,7 @@ class Project < ApplicationRecord end def external_issue_reference_pattern - external_issue_tracker.class.reference_pattern(only_long: issues_enabled?) + external_issue_tracker.reference_pattern(only_long: issues_enabled?) end def default_issues_tracker? diff --git a/config/feature_flags/development/linear_group_descendants_finder_upto.yml b/config/feature_flags/development/linear_group_descendants_finder_upto.yml index f2f4bec57da..3955332bd90 100644 --- a/config/feature_flags/development/linear_group_descendants_finder_upto.yml +++ b/config/feature_flags/development/linear_group_descendants_finder_upto.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350972 milestone: '14.8' type: development group: group::tenant scale -default_enabled: false +default_enabled: true diff --git a/db/migrate/20230222161226_add_custom_jira_regex_to_jira_tracker_data.rb b/db/migrate/20230222161226_add_custom_jira_regex_to_jira_tracker_data.rb new file mode 100644 index 00000000000..c9668c311a3 --- /dev/null +++ b/db/migrate/20230222161226_add_custom_jira_regex_to_jira_tracker_data.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddCustomJiraRegexToJiraTrackerData < Gitlab::Database::Migration[2.1] + # rubocop:disable Migration/AddLimitToTextColumns + # limit is added in 20230222161954_add_text_limit_to_custom_jira_regex_fields.rb + enable_lock_retries! + def change + add_column :jira_tracker_data, :jira_issue_prefix, :text + add_column :jira_tracker_data, :jira_issue_regex, :text + end + # rubocop:enable Migration/AddLimitToTextColumns +end diff --git a/db/migrate/20230222161954_add_text_limit_to_custom_jira_regex_fields.rb b/db/migrate/20230222161954_add_text_limit_to_custom_jira_regex_fields.rb new file mode 100644 index 00000000000..625655fda9d --- /dev/null +++ b/db/migrate/20230222161954_add_text_limit_to_custom_jira_regex_fields.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddTextLimitToCustomJiraRegexFields < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + def up + add_text_limit :jira_tracker_data, :jira_issue_prefix, 255 + add_text_limit :jira_tracker_data, :jira_issue_regex, 255 + end + + def down + remove_text_limit :jira_tracker_data, :jira_issue_regex + remove_text_limit :jira_tracker_data, :jira_issue_regex + end +end diff --git a/db/schema_migrations/20230222161226 b/db/schema_migrations/20230222161226 new file mode 100644 index 00000000000..229d4defec3 --- /dev/null +++ b/db/schema_migrations/20230222161226 @@ -0,0 +1 @@ +d51c92a4b2bb6e5d0bb3f8665f1bc6608cd2a89deb517c1f1dde3d8f0d003f79
\ No newline at end of file diff --git a/db/schema_migrations/20230222161954 b/db/schema_migrations/20230222161954 new file mode 100644 index 00000000000..1a1d84830cc --- /dev/null +++ b/db/schema_migrations/20230222161954 @@ -0,0 +1 @@ +3466379b2b26b1c77cc80b29ecdd601b9d77c94f724e210d0663cdb0ab37798e
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d3b5e247cc2..79aadffeda6 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17520,9 +17520,13 @@ CREATE TABLE jira_tracker_data ( vulnerabilities_enabled boolean DEFAULT false NOT NULL, jira_issue_transition_automatic boolean DEFAULT false NOT NULL, integration_id integer, + jira_issue_prefix text, + jira_issue_regex text, CONSTRAINT check_0bf84b76e9 CHECK ((char_length(vulnerabilities_issuetype) <= 255)), CONSTRAINT check_0fbd71d9f2 CHECK ((integration_id IS NOT NULL)), - CONSTRAINT check_214cf6a48b CHECK ((char_length(project_key) <= 255)) + CONSTRAINT check_214cf6a48b CHECK ((char_length(project_key) <= 255)), + CONSTRAINT check_4cc5bbc801 CHECK ((char_length(jira_issue_prefix) <= 255)), + CONSTRAINT check_9863a0a5fd CHECK ((char_length(jira_issue_regex) <= 255)) ); CREATE SEQUENCE jira_tracker_data_id_seq diff --git a/doc/administration/audit_event_streaming.md b/doc/administration/audit_event_streaming.md index bbf4c0699ca..afc4926fec0 100644 --- a/doc/administration/audit_event_streaming.md +++ b/doc/administration/audit_event_streaming.md @@ -46,8 +46,8 @@ Users with the Owner role for a group can add streaming destinations for it: 1. Select **Add streaming destination** to show the section for adding destinations. 1. Enter the destination URL to add. 1. Optional. Locate the **Custom HTTP headers** table. -1. Ignore the **Active** checkbox because it isn't functional. To track progress on adding functionality to the **Active** checkbox, see the - [relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/361925). +1. Ignore the **Active** checkbox because it isn't functional. To track progress on adding functionality to the + **Active** checkbox, see [issue 367509](https://gitlab.com/gitlab-org/gitlab/-/issues/367509). 1. Select **Add header** to create a new name and value pair. Enter as many name and value pairs as required. You can add up to 20 headers per streaming destination. 1. After all headers have been filled out, select **Add** to add the new streaming destination. @@ -169,8 +169,8 @@ To update a streaming destinations custom HTTP headers: 1. To the right of the item, select **Edit** (**{pencil}**). 1. Locate the **Custom HTTP headers** table. 1. Locate the header that you wish to update. -1. Ignore the **Active** checkbox because it isn't functional. To track progress on adding functionality to the **Active** checkbox, see the - [relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/361925). +1. Ignore the **Active** checkbox because it isn't functional. To track progress on adding functionality to the + **Active** checkbox, see [issue 367509](https://gitlab.com/gitlab-org/gitlab/-/issues/367509). 1. Select **Add header** to create a new name and value pair. Enter as many name and value pairs as required. You can add up to 20 headers per streaming destination. 1. Select **Save** to update the streaming destination. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 90df406cd01..f936f3096ef 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -877,6 +877,7 @@ Input type: `AdminSidekiqQueuesDeleteJobsInput` | <a id="mutationadminsidekiqqueuesdeletejobsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationadminsidekiqqueuesdeletejobsfeaturecategory"></a>`featureCategory` | [`String`](#string) | Delete jobs matching feature_category in the context metadata. | | <a id="mutationadminsidekiqqueuesdeletejobsjobid"></a>`jobId` | [`String`](#string) | Delete jobs matching job_id in the context metadata. | +| <a id="mutationadminsidekiqqueuesdeletejobsmergeactionstatus"></a>`mergeActionStatus` | [`String`](#string) | Delete jobs matching merge_action_status in the context metadata. | | <a id="mutationadminsidekiqqueuesdeletejobspipelineid"></a>`pipelineId` | [`String`](#string) | Delete jobs matching pipeline_id in the context metadata. | | <a id="mutationadminsidekiqqueuesdeletejobsproject"></a>`project` | [`String`](#string) | Delete jobs matching project in the context metadata. | | <a id="mutationadminsidekiqqueuesdeletejobsqueuename"></a>`queueName` | [`String!`](#string) | Name of the queue to delete jobs from. | @@ -20399,6 +20400,7 @@ Represents the scan result policy. | <a id="scanresultpolicyenabled"></a>`enabled` | [`Boolean!`](#boolean) | Indicates whether this policy is enabled. | | <a id="scanresultpolicygroupapprovers"></a>`groupApprovers` | [`[Group!]`](#group) | Approvers of the group type. | | <a id="scanresultpolicyname"></a>`name` | [`String!`](#string) | Name of the policy. | +| <a id="scanresultpolicyroleapprovers"></a>`roleApprovers` | [`[MemberAccessLevelName!]`](#memberaccesslevelname) | Approvers of the role type. Users belonging to these role(s) alone will be approvers. | | <a id="scanresultpolicysource"></a>`source` | [`SecurityPolicySource!`](#securitypolicysource) | Source of the policy. Its fields depend on the source type. | | <a id="scanresultpolicyupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of when the policy YAML was last updated. | | <a id="scanresultpolicyuserapprovers"></a>`userApprovers` | [`[UserCore!]`](#usercore) | Approvers of the user type. | @@ -23675,6 +23677,18 @@ Access level of a group or project member. | <a id="memberaccesslevelowner"></a>`OWNER` | Owner access. | | <a id="memberaccesslevelreporter"></a>`REPORTER` | Reporter access. | +### `MemberAccessLevelName` + +Name of access levels of a group or project member. + +| Value | Description | +| ----- | ----------- | +| <a id="memberaccesslevelnamedeveloper"></a>`DEVELOPER` | Developer access. | +| <a id="memberaccesslevelnameguest"></a>`GUEST` | Guest access. | +| <a id="memberaccesslevelnamemaintainer"></a>`MAINTAINER` | Maintainer access. | +| <a id="memberaccesslevelnameowner"></a>`OWNER` | Owner access. | +| <a id="memberaccesslevelnamereporter"></a>`REPORTER` | Reporter access. | + ### `MemberSort` Values for sorting members. diff --git a/doc/api/integrations.md b/doc/api/integrations.md index e25753b892e..c69daa70b53 100644 --- a/doc/api/integrations.md +++ b/doc/api/integrations.md @@ -944,6 +944,8 @@ Parameters: | `username` | string | yes | The username of the user created to be used with GitLab/Jira. | | `password` | string | yes | The password of the user created to be used with GitLab/Jira. | | `active` | boolean | no | Activates or deactivates the integration. Defaults to false (deactivated). | +| `jira_issue_prefix` | string | no | Prefix to match Jira issue keys. | +| `jira_issue_regex` | string | no | Regular expression to match Jira issue keys. | | `jira_issue_transition_automatic` | boolean | no | Enable [automatic issue transitions](../integration/jira/issues.md#automatic-issue-transitions). Takes precedence over `jira_issue_transition_id` if enabled. Defaults to `false` | | `jira_issue_transition_id` | string | no | The ID of one or more transitions for [custom issue transitions](../integration/jira/issues.md#custom-issue-transitions). Ignored if `jira_issue_transition_automatic` is enabled. Defaults to a blank string, which disables custom transitions. | | `commit_events` | boolean | false | Enable notifications for commit events | diff --git a/doc/ci/pipelines/cicd_minutes.md b/doc/ci/pipelines/cicd_minutes.md index e1eb2f53910..e86888d9263 100644 --- a/doc/ci/pipelines/cicd_minutes.md +++ b/doc/ci/pipelines/cicd_minutes.md @@ -7,6 +7,9 @@ type: reference # CI/CD minutes quota **(PREMIUM)** +NOTE: +`CI/CD minutes` is being renamed to `compute credits`. During this transition, you might see references in the UI and documentation to `CI minutes`, `CI/CD minutes`, `pipeline minutes`, `CI pipeline minutes`, and `compute credits`. All of these terms refer to compute credits. + Administrators can limit the amount of time that projects can use to run jobs on [shared runners](../runners/runners_scope.md#shared-runners) each month. This limit is tracked with a quota of CI/CD minutes. diff --git a/doc/integration/jira/issues.md b/doc/integration/jira/issues.md index 58da871926b..9f8f98bfa5e 100644 --- a/doc/integration/jira/issues.md +++ b/doc/integration/jira/issues.md @@ -62,6 +62,41 @@ After you enable this feature, a merge request that doesn't reference an associa Jira issue can't be merged. The merge request displays the message **To merge, a Jira issue key must be mentioned in the title or description.** +## Customize Jira issue matching in GitLab + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112826) in GitLab 15.10. + +You can configure custom rules for how GitLab matches Jira issue keys by defining: + +- [A regex pattern](#use-regular-expression) +- [A prefix](#use-a-prefix) + +When you don't configure custom rules, the [default behavior](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/regex.rb#L509) is used. For more information, see the [RE2 wiki](https://github.com/google/re2/wiki/Syntax). + +### Use regular expression + +To define a regex pattern for Jira issue keys: + +1. On the top bar, select **Main menu > Projects** and find your project. +1. On the left sidebar, select **Settings > Integrations**. +1. Select **Jira**. +1. Go to the **Jira issue matching** section. +1. In the **Jira issue regex** text box, enter a regex pattern. +1. Select **Save changes**. + +For more information, see the [Atlassian documentation](https://confluence.atlassian.com/adminjiraserver073/changing-the-project-key-format-861253229.html). + +### Use a prefix + +To define a prefix for Jira issue keys: + +1. On the top bar, select **Main menu > Projects** and find your project. +1. On the left sidebar, select **Settings > Integrations**. +1. Select **Jira**. +1. Go to the **Jira issue matching** section. +1. In the **Jira issue prefix** text box, enter a prefix. +1. Select **Save changes**. + ## Close Jira issues in GitLab If you have configured GitLab transition IDs, you can close a Jira issue directly diff --git a/doc/topics/your_work.md b/doc/topics/your_work.md index 268a6c4df5b..e8804867c96 100644 --- a/doc/topics/your_work.md +++ b/doc/topics/your_work.md @@ -6,9 +6,12 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Your work sidebar -- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/384342) in GitLab 15.9. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/384342) in GitLab 15.9. -The **Your work** left sidebar provides access to your: +The **Your work** sidebar is a more focused view into the areas you have access to. + +This sidebar is part of [a redesign effort](https://gitlab.com/groups/gitlab-org/-/epics/9044) +that removes the top bar and enables more customization of your GitLab experience. - [Projects](../user/project/working_with_projects.md#view-projects) - [Groups](../user/group/index.md) diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index 61f248a0c63..71144ef931b 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -20,13 +20,7 @@ You can configure SAML SSO for the top-level group only. ## Configure your identity provider -1. Find the information in GitLab required for configuration: - 1. On the top bar, select **Main menu > Groups** and find your group. - 1. On the left sidebar, select **Settings > SAML SSO**. - 1. Note the **Assertion consumer service URL**, **Identifier**, and **GitLab single sign-on URL**. -1. Configure your SAML identity provider app using the noted details. - Alternatively, GitLab provides a [metadata XML configuration](#set-up-identity-provider-using-metadata). - See [specific identity provider documentation](#set-up-identity-provider) for more details. +1. [Configure your SAML identity provider](#set-up-identity-provider). 1. Configure the SAML response to include a [NameID](#nameid) that uniquely identifies each user. 1. Configure the required [user attributes](#user-attributes), ensuring you include the user's email address. 1. While the default is enabled for most SAML providers, ensure the app is set to have service provider @@ -69,7 +63,7 @@ To set up SSO with Azure as your identity provider: 1. You should set the following attributes: - **Unique User Identifier (Name identifier)** to `user.objectID`. - - **nameid-format** to `persistent`. + - **nameid-format** to `persistent`. For more information, see the [NameID documentation](#nameid). - **Additional claims** to [supported attributes](#user-attributes). 1. Optional. If you use [Group Sync](#group-sync), customize the name of the @@ -106,11 +100,11 @@ To set up Google Workspace as your identity provider: ``` 1. Set these values: - - For **Primary email**: `email` - - For **First name**: `first_name` - - For **Last name**: `last_name` - - For **Name ID format**: `EMAIL` - - For **NameID**: `Basic Information > Primary email` + - For **Primary email**: `email`. + - For **First name**: `first_name`. + - For **Last name**: `last_name`. + - For **Name ID format**: `EMAIL`. For more information, see the [NameID format documentation](#nameid-format). + - For **NameID**: `Basic Information > Primary email`. For more information, see the [NameID documentation](#nameid). On the GitLab SAML SSO page, when you select **Verify SAML Configuration**, disregard the warning that recommends setting the **NameID** format to `persistent`. @@ -119,47 +113,60 @@ For more information, see an [example configuration page](example_saml_config.md ### Set up Okta -<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> -For a demo of the Okta SAML setup including SCIM, see [Demo: Okta Group SAML & SCIM setup](https://youtu.be/0ES9HsZq0AQ). +To set up SSO with Okta as your identity provider: + +1. In GitLab, on the top bar, select **Main menu > Groups** and find your group. +1. On the left sidebar, select **Settings > SAML SSO**. +1. Note the information on this page. +1. Follow the instructions for [setting up a SAML application in Okta](https://developer.okta.com/docs/guides/build-sso-integration/saml2/main/). -1. [Set up a SAML application in Okta](https://developer.okta.com/docs/guides/build-sso-integration/saml2/main/). The following GitLab settings correspond to the Okta fields. - | GitLab setting | Okta field | - | ------------------------------------ | ---------------------------------------------------------- | - | Identifier | **Audience URI** | - | Assertion consumer service URL | **Single sign-on URL** | - | GitLab single sign-on URL | **Login page URL** (under **Application Login Page** settings) | - | Identity provider single sign-on URL | **Identity Provider Single Sign-On URL** | + | GitLab setting | Okta field | + | ---------------------------------------- | -------------------------------------------------------------- | + | **Identifier** | **Audience URI** | + | **Assertion consumer service URL** | **Single sign-on URL** | + | **GitLab single sign-on URL** | **Login page URL** (under **Application Login Page** settings) | + | **Identity provider single sign-on URL** | **Identity Provider Single Sign-On URL** | 1. Under the Okta **Single sign-on URL** field, select the **Use this for Recipient URL and Destination URL** checkbox. 1. Set these values: - - For **Application username (NameID)**: **Custom** `user.getInternalProperty("id")` - - For **Name ID Format**: `Persistent` + - For **Application username (NameID)**: **Custom** `user.getInternalProperty("id")`. + - For **Name ID Format**: `Persistent`. For more information, see the [NameID documentation](#nameid). The Okta GitLab application available in the App Catalog only supports [SCIM](scim_setup.md). Support for SAML is proposed in [issue 216173](https://gitlab.com/gitlab-org/gitlab/-/issues/216173). +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +For a demo of the Okta SAML setup including SCIM, see [Demo: Okta Group SAML & SCIM setup](https://youtu.be/0ES9HsZq0AQ). + +For more information, see an [example configuration page](example_saml_config.md#okta) + ### Set up OneLogin OneLogin supports its own [GitLab (SaaS) application](https://onelogin.service-now.com/support?id=kb_article&sys_id=92e4160adbf16cd0ca1c400e0b961923&kb_category=50984e84db738300d5505eea4b961913). +To set up OneLogin as your identity provider: + +1. In GitLab, on the top bar, select **Main menu > Groups** and find your group. +1. On the left sidebar, select **Settings > SAML SSO**. +1. Note the information on this page. 1. If you use the OneLogin generic [SAML Test Connector (Advanced)](https://onelogin.service-now.com/support?id=kb_article&sys_id=b2c19353dbde7b8024c780c74b9619fb&kb_category=93e869b0db185340d5505eea4b961934), you should [use the OneLogin SAML Test Connector](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f). The following GitLab settings correspond to the OneLogin fields: - | GitLab setting | OneLogin field | - | ------------------------------------------------ | -------------------------------- | - | Identifier | **Audience** | - | Assertion consumer service URL | **Recipient** | - | Assertion consumer service URL | **ACS (Consumer) URL** | - | Assertion consumer service URL (escaped version) | **ACS (Consumer) URL Validator** | - | GitLab single sign-on URL | **Login URL** | - | Identity provider single sign-on URL | **SAML 2.0 Endpoint** | + | GitLab setting | OneLogin field | + | ---------------------------------------------------- | -------------------------------- | + | **Identifier** | **Audience** | + | **Assertion consumer service URL** | **Recipient** | + | **Assertion consumer service URL** | **ACS (Consumer) URL** | + | **Assertion consumer service URL (escaped version)** | **ACS (Consumer) URL Validator** | + | **GitLab single sign-on URL** | **Login URL** | + | **Identity provider single sign-on URL** | **SAML 2.0 Endpoint** | -1. For **NameID**, use `OneLogin ID`. +1. For **NameID**, use `OneLogin ID`. For more information, see the [NameID documentation](#nameid). ### Set up identity provider using metadata @@ -173,28 +180,6 @@ To find this URL: Check your identity provider's documentation to see if it supports the GitLab metadata URL. -### NameID - -GitLab.com uses the SAML NameID to identify users. The NameID element: - -- Is a required field in the SAML response. -- Must be unique to each user. -- Must be a persistent value that never changes, such as a randomly generated unique user ID. -- Is case sensitive. The NameID must match exactly on subsequent login attempts, so should not rely on user input that could change between upper and lower case. -- Should not be an email address or username. We strongly recommend against these as it's hard to - guarantee it doesn't ever change, for example, when a person's name changes. Email addresses are - also case-insensitive, which can result in users being unable to sign in. - -The relevant field name and recommended value for supported providers are in the [provider specific notes](#set-up-identity-provider). - -WARNING: -Once users have signed into GitLab using the SSO SAML setup, changing the `NameID` breaks the configuration and potentially locks users out of the GitLab group. - -#### NameID Format - -We recommend setting the NameID format to `Persistent` unless using a field (such as email) that requires a different format. -Most NameID formats can be used, except `Transient` due to the temporary nature of this format. - ### User attributes To create users with the correct information for improved [user access and management](#user-access-and-management), @@ -319,14 +304,14 @@ After you have configured your identity provider, you can: - Migrate to a different identity provider. - Change email domains. -### Change the identity provider +#### Change the identity provider To change the identity provider: - If the `NameID` is not identical in the existing and new identity providers, [change the NameID for users](#change-nameid-for-one-or-more-users). - If the `NameID` is identical, users do not have to make any changes. -### Migrate to a different identity provider +#### Migrate to a different identity provider You can migrate to a different identity provider. During the migration process, users cannot access any of the SAML groups. To mitigate this, you can disable @@ -337,7 +322,7 @@ To migrate identity providers: 1. [Configure](#configure-your-identity-provider) the group with the new identity provider. 1. [Change the NameID for users](#change-nameid-for-one-or-more-users). -### Change email domains +#### Change email domains To migrate users to a new email domain, tell users to: @@ -474,7 +459,7 @@ To rescind a user's access to the group when only SAML SSO is configured, either - Remove (in order) the user from: 1. The user data store on the identity provider or the list of users on the specific app. 1. The GitLab.com group. -- Use [Group Sync](group_sync.md#automatic-member-removal) at the top-level of your group with the [default role](#role) set to [minimal access](../../permissions.md#users-with-minimal-access) to automatically block access to all resources within the group. Users may continue to [use a seat](../../permissions.md#minimal-access-users-take-license-seats). +- Use [Group Sync](group_sync.md#automatic-member-removal) at the top-level of your group with the [default role](#role) set to [minimal access](../../permissions.md#users-with-minimal-access) to automatically block access to all resources within the group. To rescind a user's access to the group when also using SCIM, refer to [Remove access](scim_setup.md#remove-access). @@ -508,6 +493,28 @@ For information on automatically managing GitLab group membership, see [SAML Gro The [Generated passwords for users created through integrated authentication](../../../security/passwords_for_integrated_authentication_methods.md) guide provides an overview of how GitLab generates and sets passwords for users created via SAML SSO for Groups. +### NameID + +GitLab.com uses the SAML NameID to identify users. The NameID element: + +- Is a required field in the SAML response. +- Must be unique to each user. +- Must be a persistent value that never changes, such as a randomly generated unique user ID. +- Is case sensitive. The NameID must match exactly on subsequent login attempts, so should not rely on user input that could change between upper and lower case. +- Should not be an email address or username. We strongly recommend against these as it's hard to + guarantee it doesn't ever change, for example, when a person's name changes. Email addresses are + also case-insensitive, which can result in users being unable to sign in. + +The relevant field name and recommended value for supported providers are in the [provider specific notes](#set-up-identity-provider). + +WARNING: +Once users have signed into GitLab using the SSO SAML setup, changing the `NameID` breaks the configuration and potentially locks users out of the GitLab group. + +#### NameID Format + +We recommend setting the NameID format to `Persistent` unless using a field (such as email) that requires a different format. +Most NameID formats can be used, except `Transient` due to the temporary nature of this format. + ## Related topics For more information on: diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 43029e37047..3a09e1078ca 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -427,8 +427,12 @@ For more information, see > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40942) in GitLab 13.4. -Owners can add members with a "minimal access" role to a root group. Such users don't automatically have access to -projects and subgroups underneath. Owners must explicitly add these "minimal access" users to the specific subgroups and +Owners can add members with a "minimal access" role to a root group. Such users do not: + +- Count as licensed seats. +- Automatically have access to projects and subgroups in that root group. + +Owners must explicitly add these "minimal access" users to the specific subgroups and projects. You can use minimal access to give the same member more than one role in a group: @@ -443,12 +447,6 @@ Because of an [outstanding issue](https://gitlab.com/gitlab-org/gitlab/-/issues/ To work around the issue, give these users the Guest role or higher to any project or subgroup within the parent group. -### Minimal access users take license seats - -Users with even a "minimal access" role are counted against your number of license seats. This -requirement does not apply for [GitLab Ultimate](https://about.gitlab.com/pricing/) -subscriptions. - ## Related topics - [The GitLab principles behind permissions](https://about.gitlab.com/handbook/product/gitlab-the-product/#permissions-in-gitlab) diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index e9de780655a..4641af262ca 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -142,7 +142,7 @@ To edit a milestone: 1. On the top bar, select **Main menu > Projects** and find your project or **Main menu > Groups** and find your group. 1. Select a milestone's title. -1. Select **Edit**. +1. In the top right corner, select **Milestone actions** (**{ellipsis_v}**) and then select **Edit**. 1. Edit the title, start date, due date, or description. 1. Select **Save changes**. @@ -158,7 +158,7 @@ To edit a milestone: 1. On the top bar, select **Main menu > Projects** and find your project or **Main menu > Groups** and find your group. 1. Select a milestone's title. -1. Select **Delete**. +1. In the top right corner, select **Milestone actions** (**{ellipsis_v}**) and then select **Delete**. 1. Select **Delete milestone**. ## Promote a project milestone to a group milestone @@ -185,7 +185,7 @@ To promote a project milestone: 1. On the left sidebar, select **Issues > Milestones**. 1. Either: - Select **Promote to Group Milestone** (**{level-up}**) next to the milestone you want to promote. - - Select the milestone title, and then select **Promote**. + - Select the milestone title, and then select **Milestone actions** (**{ellipsis_v}**) > **Promote**. 1. Select **Promote Milestone**. ## Assign a milestone to an issue or merge request diff --git a/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png b/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png Binary files differindex 09b30af91d0..8472015c21b 100644 --- a/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png +++ b/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb index c85871d4b8c..a5ed029c978 100644 --- a/lib/api/helpers/integrations_helpers.rb +++ b/lib/api/helpers/integrations_helpers.rb @@ -611,6 +611,18 @@ module API }, { required: false, + name: :jira_issue_prefix, + type: String, + desc: 'Prefix to match Jira issue keys' + }, + { + required: false, + name: :jira_issue_regex, + type: String, + desc: 'Regular expression to match Jira issue keys' + }, + { + required: false, name: :comment_on_event_enabled, type: Boolean, desc: 'Enable comments inside Jira issues on each GitLab event (commit / merge request)' diff --git a/lib/atlassian/jira_issue_key_extractor.rb b/lib/atlassian/jira_issue_key_extractor.rb index 968e8b0f82e..881ba4544b2 100644 --- a/lib/atlassian/jira_issue_key_extractor.rb +++ b/lib/atlassian/jira_issue_key_extractor.rb @@ -6,12 +6,13 @@ module Atlassian new(...).issue_keys.any? end - def initialize(*text) + def initialize(*text, custom_regex: nil) @text = text.join(' ') + @match_regex = custom_regex || Gitlab::Regex.jira_issue_key_regex end def issue_keys - @text.scan(Gitlab::Regex.jira_issue_key_regex).uniq + @text.scan(@match_regex).flatten.uniq end end end diff --git a/lib/banzai/filter/references/iteration_reference_filter.rb b/lib/banzai/filter/references/iteration_reference_filter.rb deleted file mode 100644 index 591e07013c3..00000000000 --- a/lib/banzai/filter/references/iteration_reference_filter.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - module References - # The actual filter is implemented in the EE mixin - class IterationReferenceFilter < AbstractReferenceFilter - self.reference_type = :iteration - self.object_class = Iteration - end - end - end -end - -Banzai::Filter::References::IterationReferenceFilter.prepend_mod_with('Banzai::Filter::References::IterationReferenceFilter') diff --git a/lib/banzai/reference_parser/iteration_parser.rb b/lib/banzai/reference_parser/iteration_parser.rb deleted file mode 100644 index 981354aa8e1..00000000000 --- a/lib/banzai/reference_parser/iteration_parser.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module ReferenceParser - # The actual parser is implemented in the EE mixin - class IterationParser < BaseParser - self.reference_type = :iteration - - def references_relation - Iteration - end - - private - - def can_read_reference?(_user, _ref_project, _node) - false - end - end - end -end - -Banzai::ReferenceParser::IterationParser.prepend_mod_with('Banzai::ReferenceParser::IterationParser') diff --git a/lib/gitlab/application_context.rb b/lib/gitlab/application_context.rb index 06ce1dbdc77..0ea52b7b7c8 100644 --- a/lib/gitlab/application_context.rb +++ b/lib/gitlab/application_context.rb @@ -25,7 +25,8 @@ module Gitlab :artifact_used_cdn, :artifacts_dependencies_size, :artifacts_dependencies_count, - :root_caller_id + :root_caller_id, + :merge_action_status ].freeze private_constant :KNOWN_KEYS @@ -43,7 +44,8 @@ module Gitlab Attribute.new(:artifact_used_cdn, Object), Attribute.new(:artifacts_dependencies_size, Integer), Attribute.new(:artifacts_dependencies_count, Integer), - Attribute.new(:root_caller_id, String) + Attribute.new(:root_caller_id, String), + Attribute.new(:merge_action_status, String) ].freeze def self.known_keys @@ -97,6 +99,7 @@ module Gitlab assign_hash_if_value(hash, :artifact_used_cdn) assign_hash_if_value(hash, :artifacts_dependencies_size) assign_hash_if_value(hash, :artifacts_dependencies_count) + assign_hash_if_value(hash, :merge_action_status) hash[:user] = -> { username } if include_user? hash[:user_id] = -> { user_id } if include_user? diff --git a/lib/gitlab/content_security_policy/config_loader.rb b/lib/gitlab/content_security_policy/config_loader.rb index 477877e6a7c..ceca206b084 100644 --- a/lib/gitlab/content_security_policy/config_loader.rb +++ b/lib/gitlab/content_security_policy/config_loader.rb @@ -50,7 +50,6 @@ module Gitlab allow_sentry(directives) if Gitlab::CurrentSettings.try(:sentry_enabled) && Gitlab::CurrentSettings.try(:sentry_clientside_dsn) allow_framed_gitlab_paths(directives) allow_customersdot(directives) if ENV['CUSTOMER_PORTAL_URL'].present? - allow_kas(directives) allow_review_apps(directives) if ENV['REVIEW_APPS_ENABLED'] # The follow section contains workarounds to patch Safari's lack of support for CSP Level 3 @@ -148,17 +147,6 @@ module Gitlab append_to_directive(directives, 'frame_src', customersdot_host) end - def self.allow_kas(directives) - return unless ::Gitlab::Kas::UserAccess.enabled? - - kas_url = ::Gitlab::Kas.tunnel_url - return if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception - - kas_url += '/' unless kas_url.end_with?('/') - - append_to_directive(directives, 'connect_src', kas_url) - end - def self.allow_legacy_sentry(directives) # Support for Sentry setup via configuration files will be removed in 16.0 # in favor of Gitlab::CurrentSettings. diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 3a342abe65d..c1f4b8d5e25 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -79,63 +79,6 @@ module Gitlab end end - # @deprecated Use `create_table` in V2 instead - # - # Creates a new table, optionally allowing the caller to add check constraints to the table. - # Aside from that addition, this method should behave identically to Rails' `create_table` method. - # - # Example: - # - # create_table_with_constraints :some_table do |t| - # t.integer :thing, null: false - # t.text :other_thing - # - # t.check_constraint :thing_is_not_null, 'thing IS NOT NULL' - # t.text_limit :other_thing, 255 - # end - # - # See Rails' `create_table` for more info on the available arguments. - def create_table_with_constraints(table_name, **options, &block) - helper_context = self - - with_lock_retries do - check_constraints = [] - - create_table(table_name, **options) do |t| - t.define_singleton_method(:check_constraint) do |name, definition| - helper_context.send(:validate_check_constraint_name!, name) # rubocop:disable GitlabSecurity/PublicSend - - check_constraints << { name: name, definition: definition } - end - - t.define_singleton_method(:text_limit) do |column_name, limit, name: nil| - # rubocop:disable GitlabSecurity/PublicSend - name = helper_context.send(:text_limit_name, table_name, column_name, name: name) - helper_context.send(:validate_check_constraint_name!, name) - # rubocop:enable GitlabSecurity/PublicSend - - column_name = helper_context.quote_column_name(column_name) - definition = "char_length(#{column_name}) <= #{limit}" - - check_constraints << { name: name, definition: definition } - end - - t.instance_eval(&block) unless block.nil? - end - - next if check_constraints.empty? - - constraint_clauses = check_constraints.map do |constraint| - "ADD CONSTRAINT #{quote_table_name(constraint[:name])} CHECK (#{constraint[:definition]})" - end - - execute(<<~SQL) - ALTER TABLE #{quote_table_name(table_name)} - #{constraint_clauses.join(",\n")} - SQL - end - end - # Creates a new index, concurrently # # Example: diff --git a/lib/gitlab/database/migration_helpers/v2.rb b/lib/gitlab/database/migration_helpers/v2.rb index b5b8b58681c..ef48d601eb9 100644 --- a/lib/gitlab/database/migration_helpers/v2.rb +++ b/lib/gitlab/database/migration_helpers/v2.rb @@ -5,24 +5,6 @@ module Gitlab module MigrationHelpers module V2 include Gitlab::Database::MigrationHelpers - - # Superseded by `create_table` override below - def create_table_with_constraints(*_) - raise <<~EOM - #create_table_with_constraints is not supported anymore - use #create_table instead, for example: - - create_table :db_guides do |t| - t.bigint :stars, default: 0, null: false - t.text :title, limit: 128 - t.text :notes, limit: 1024 - - t.check_constraint 'stars > 1000', name: 'so_many_stars' - end - - See https://docs.gitlab.com/ee/development/database/strings_and_the_text_data_type.html - EOM - end - # Creates a new table, optionally allowing the caller to add text limit constraints to the table. # This method only extends Rails' `create_table` method # diff --git a/lib/gitlab/import_export/project/object_builder.rb b/lib/gitlab/import_export/project/object_builder.rb index 0962ad9f028..ac28ae6bfe0 100644 --- a/lib/gitlab/import_export/project/object_builder.rb +++ b/lib/gitlab/import_export/project/object_builder.rb @@ -60,7 +60,7 @@ module Gitlab def prepare_attributes attributes.dup.tap do |atts| - atts.delete('group') unless epic? || iteration? + atts.delete('group') unless group_level_object? if label? atts['type'] = 'ProjectLabel' # Always create project labels @@ -142,10 +142,6 @@ module Gitlab klass == MergeRequestDiffCommit end - def iteration? - klass == Iteration - end - # If an existing group milestone used the IID # claim the IID back and set the group milestone to use one available # This is necessary to fix situations like the following: @@ -164,7 +160,11 @@ module Gitlab end def group_relation_without_group? - (epic? || iteration?) && group.nil? + group_level_object? && group.nil? + end + + def group_level_object? + epic? end end end diff --git a/lib/gitlab/import_export/project/relation_tree_restorer.rb b/lib/gitlab/import_export/project/relation_tree_restorer.rb index 47196db6f8a..b5247754199 100644 --- a/lib/gitlab/import_export/project/relation_tree_restorer.rb +++ b/lib/gitlab/import_export/project/relation_tree_restorer.rb @@ -5,10 +5,14 @@ module Gitlab module Project class RelationTreeRestorer < ImportExport::Group::RelationTreeRestorer # Relations which cannot be saved at project level (and have a group assigned) - GROUP_MODELS = [GroupLabel, Milestone, Epic, Iteration].freeze + GROUP_MODELS = [GroupLabel, Milestone, Epic].freeze private + def group_models + GROUP_MODELS + end + def bulk_insert_enabled true end @@ -19,9 +23,11 @@ module Gitlab end def relation_invalid_for_importable?(relation_object) - GROUP_MODELS.include?(relation_object.class) && relation_object.group_id + group_models.include?(relation_object.class) && relation_object.group_id end end end end end + +Gitlab::ImportExport::Project::RelationTreeRestorer.prepend_mod diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 540394f04bd..783b68fac12 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -4,7 +4,7 @@ module Gitlab # Extract possible GFM references from an arbitrary String for further processing. class ReferenceExtractor < Banzai::ReferenceExtractor REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project - merge_request snippet commit commit_range directly_addressed_user epic iteration vulnerability + merge_request snippet commit commit_range directly_addressed_user epic vulnerability alert).freeze attr_accessor :project, :current_user, :author @@ -64,18 +64,24 @@ module Gitlab end def all - REFERABLES.each { |referable| send(referable.to_s.pluralize) } # rubocop:disable GitlabSecurity/PublicSend + self.class.referrables.each { |referable| send(referable.to_s.pluralize) } # rubocop:disable GitlabSecurity/PublicSend @references.values.flatten end - def self.references_pattern - return @pattern if @pattern + class << self + def references_pattern + return @pattern if @pattern - patterns = REFERABLES.map do |type| - Banzai::ReferenceParser[type].reference_class.try(:reference_pattern) - end.uniq + patterns = referrables.map do |type| + Banzai::ReferenceParser[type].reference_class.try(:reference_pattern) + end.uniq - @pattern = Regexp.union(patterns.compact) + @pattern = Regexp.union(patterns.compact) + end + + def referrables + @referrables ||= REFERABLES + end end private @@ -90,3 +96,5 @@ module Gitlab end end end + +Gitlab::ReferenceExtractor.prepend_mod diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9d1941d6f5e..c5471e1e183 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10886,6 +10886,9 @@ msgstr "" msgid "Configure advanced permissions, Large File Storage, two-factor authentication, and customer relations settings." msgstr "" +msgid "Configure custom rules for Jira issue key matching" +msgstr "" + msgid "Configure existing installation" msgstr "" @@ -19674,6 +19677,9 @@ msgstr "" msgid "GlobalSearch|Search GitLab" msgstr "" +msgid "GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}" +msgstr "" + msgid "GlobalSearch|Search for projects, issues, etc." msgstr "" @@ -19692,6 +19698,9 @@ msgstr "" msgid "GlobalSearch|Syntax options" msgstr "" +msgid "GlobalSearch|The search term must be at least 3 characters long." +msgstr "" + msgid "GlobalSearch|There was an error fetching search autocomplete suggestions." msgstr "" @@ -24399,6 +24408,9 @@ msgstr "" msgid "Jira integration not configured." msgstr "" +msgid "Jira issue matching" +msgstr "" + msgid "Jira project key is not configured." msgstr "" @@ -24573,6 +24585,12 @@ msgstr "" msgid "JiraService|Jira comments are created when an issue is referenced in a merge request." msgstr "" +msgid "JiraService|Jira issue prefix" +msgstr "" + +msgid "JiraService|Jira issue regex" +msgstr "" + msgid "JiraService|Jira issues" msgstr "" @@ -24621,9 +24639,15 @@ msgstr "" msgid "JiraService|Use Jira as this project's issue tracker." msgstr "" +msgid "JiraService|Use a prefix to match Jira issue keys." +msgstr "" + msgid "JiraService|Use custom transitions" msgstr "" +msgid "JiraService|Use regular expression to match Jira issue keys." +msgstr "" + msgid "JiraService|Username for the server version or an email for the cloud version" msgstr "" @@ -38203,6 +38227,9 @@ msgstr "" msgid "SSL verification" msgstr "" +msgid "SVG could not be rendered correctly: " +msgstr "" + msgid "Sat" msgstr "" @@ -44392,6 +44419,9 @@ msgstr "" msgid "This code snippet contains everything reflected in the configuration form. Copy and paste it into %{linkStart}.gitlab-ci.yml%{linkEnd} file and save your changes. Future %{scanType} scans will use these settings." msgstr "" +msgid "This comment appears to have a token in it. Are you sure you want to add it?" +msgstr "" + msgid "This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost." msgstr "" @@ -46658,6 +46688,9 @@ msgstr "" msgid "Upvotes" msgstr "" +msgid "Usage" +msgstr "" + msgid "Usage Trends" msgstr "" @@ -46754,6 +46787,9 @@ msgstr "" msgid "UsageQuota|Local proxy used for frequently-accessed upstream Docker images. %{linkStart}More information%{linkEnd}" msgstr "" +msgid "UsageQuota|Month" +msgstr "" + msgid "UsageQuota|Namespace storage used" msgstr "" @@ -46844,6 +46880,9 @@ msgstr "" msgid "UsageQuota|Transfer" msgstr "" +msgid "UsageQuota|Transfer data used by month" +msgstr "" + msgid "UsageQuota|Uploads" msgstr "" diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb index d14b1bdd6bb..db8fc079774 100644 --- a/rubocop/migration_helpers.rb +++ b/rubocop/migration_helpers.rb @@ -21,7 +21,7 @@ module RuboCop # or through a create/alter table (TABLE_METHODS) ADD_COLUMN_METHODS = %i(add_column change_column_type_concurrently).freeze - TABLE_METHODS = %i(create_table create_table_if_not_exists change_table create_table_with_constraints).freeze + TABLE_METHODS = %i(create_table create_table_if_not_exists change_table).freeze def high_traffic_tables @high_traffic_tables ||= rubocop_migrations_config.dig('Migration/UpdateLargeTable', 'HighTrafficTables') diff --git a/scripts/create-pipeline-failure-incident.rb b/scripts/create-pipeline-failure-incident.rb index bd57abf3740..05cba98d2fc 100755 --- a/scripts/create-pipeline-failure-incident.rb +++ b/scripts/create-pipeline-failure-incident.rb @@ -15,7 +15,6 @@ class CreatePipelineFailureIncident project: nil, incident_json_file: 'incident.json' }.freeze - DEFAULT_LABELS = ['Engineering Productivity', 'master-broken::undetermined'].freeze def initialize(options) @project = options.delete(:project) @@ -48,6 +47,10 @@ class CreatePipelineFailureIncident ENV['CI_COMMIT_REF_NAME'] =~ /^[\d-]+-stable(-ee)?$/ end + def review_apps_incident? + project.end_with?('review-apps-broken-incidents') + end + def failed_jobs @failed_jobs ||= PipelineFailedJobs.new(API::DEFAULT_OPTIONS.merge(exclude_allowed_to_fail_jobs: true)).execute end @@ -76,9 +79,13 @@ class CreatePipelineFailureIncident end def description - return broken_stable_description_content if stable_branch_incident? - - broken_master_description_content + if stable_branch_incident? + broken_stable_description_content + elsif review_apps_incident? + broken_review_apps_description_content + else + broken_master_description_content + end end def broken_master_description_content @@ -177,17 +184,41 @@ class CreatePipelineFailureIncident MARKDOWN end - def incident_labels - return ['release-blocker'] if stable_branch_incident? + def broken_review_apps_description_content + <<~MARKDOWN + ## #{project_link} pipeline #{pipeline_link} failed - master_broken_label = - if ENV['CI_PROJECT_NAME'] == 'gitlab-foss' - 'master:foss-broken' - else - 'master:broken' - end + **Branch: #{branch_link}** - DEFAULT_LABELS.dup << master_broken_label + **Commit: #{commit_link}** + + **Triggered by** #{triggered_by_link} • **Source:** #{source} • **Duration:** #{pipeline_duration} minutes + + **Failed jobs (#{failed_jobs.size}):** + + #{failed_jobs_list} + + ### General guidelines + + Please refer to [the review-apps triaging process](https://gitlab.com/gitlab-org/quality/engineering-productivity/team/-/blob/main/runbooks/review-apps.md#review-apps-broken-slack-channel-isnt-empty). + MARKDOWN + end + + def incident_labels + if stable_branch_incident? + ['release-blocker'] + elsif review_apps_incident? + ['review-apps-broken', 'Engineering Productivity', 'ep::review-apps'] + else + master_broken_label = + if ENV['CI_PROJECT_NAME'] == 'gitlab-foss' + 'master:foss-broken' + else + 'master:broken' + end + + [master_broken_label, 'Engineering Productivity', 'master-broken::undetermined'] + end end def assignee_ids @@ -249,7 +280,7 @@ if $PROGRAM_NAME == __FILE__ end opts.on("-f", "--incident-json-file file_path", String, "Path to a file where to save the incident JSON data "\ - "(defaults to `#{CreatePipelineFailureIncident::DEFAULT_OPTIONS[:incident_json_file]}`)") do |value| + "(defaults to `#{CreatePipelineFailureIncident::DEFAULT_OPTIONS[:incident_json_file] || 'nil'}`)") do |value| options[:incident_json_file] = value end diff --git a/scripts/generate-failed-pipeline-slack-message.rb b/scripts/generate-failed-pipeline-slack-message.rb index eefdebd5db5..2a406ad7e23 100755 --- a/scripts/generate-failed-pipeline-slack-message.rb +++ b/scripts/generate-failed-pipeline-slack-message.rb @@ -9,11 +9,13 @@ require_relative 'api/pipeline_failed_jobs' class GenerateFailedPipelineSlackMessage DEFAULT_OPTIONS = { + project: nil, failed_pipeline_slack_message_file: 'failed_pipeline_slack_message.json', incident_json_file: 'incident.json' }.freeze def initialize(options) + @project = options.delete(:project) @incident_json_file = options.delete(:incident_json_file) end @@ -73,7 +75,7 @@ class GenerateFailedPipelineSlackMessage private - attr_reader :incident_json_file + attr_reader :project, :incident_json_file def failed_jobs @failed_jobs ||= PipelineFailedJobs.new(API::DEFAULT_OPTIONS.dup.merge(exclude_allowed_to_fail_jobs: true)).execute @@ -107,7 +109,7 @@ class GenerateFailedPipelineSlackMessage if incident_exist? incident['web_url'] else - "#{ENV['CI_SERVER_URL']}/#{ENV['BROKEN_BRANCH_INCIDENTS_PROJECT']}/-/issues/new?" \ + "#{ENV['CI_SERVER_URL']}/#{project}/-/issues/new?" \ "issuable_template=incident&issue%5Bissue_type%5D=incident" end end @@ -153,6 +155,11 @@ if $PROGRAM_NAME == __FILE__ options = GenerateFailedPipelineSlackMessage::DEFAULT_OPTIONS.dup OptionParser.new do |opts| + opts.on("-p", "--project PROJECT", String, "Full project path where the incidents are stored (defaults to "\ + "`#{GenerateFailedPipelineSlackMessage::DEFAULT_OPTIONS[:project]}`)") do |value| + options[:project] = value + end + opts.on("-i", "--incident-json-file file_path", String, "Path to a file where the incident JSON data "\ "can be found (defaults to "\ "`#{GenerateFailedPipelineSlackMessage::DEFAULT_OPTIONS[:incident_json_file]}`)") do |value| diff --git a/scripts/review_apps/automated_cleanup.rb b/scripts/review_apps/automated_cleanup.rb index 0fe6867483d..36472056e76 100755 --- a/scripts/review_apps/automated_cleanup.rb +++ b/scripts/review_apps/automated_cleanup.rb @@ -24,6 +24,25 @@ module ReviewApps ].freeze ENVIRONMENTS_NOT_FOUND_THRESHOLD = 3 + def self.parse_args(argv) + options = { + dry_run: false + } + + OptionParser.new do |opts| + opts.on("-d BOOLEAN", "--dry-run BOOLEAN", String, "Whether to perform a dry-run or not.") do |value| + options[:dry_run] = true if value == 'true' + end + + opts.on("-h", "--help", "Prints this help") do + puts opts + exit + end + end.parse!(argv) + + options + end + # $GITLAB_PROJECT_REVIEW_APP_CLEANUP_API_TOKEN => `Automated Review App Cleanup` project token def initialize( project_path: ENV['CI_PROJECT_PATH'], @@ -279,21 +298,7 @@ def timed(task) end if $PROGRAM_NAME == __FILE__ - options = { - dry_run: false - } - - OptionParser.new do |opts| - opts.on("-d", "--dry-run BOOLEAN", String, "Whether to perform a dry-run or not.") do |value| - options[:dry_run] = true if value == 'true' - end - - opts.on("-h", "--help", "Prints this help") do - puts opts - exit - end - end.parse! - + options = ReviewApps::AutomatedCleanup.parse_args(ARGV) automated_cleanup = ReviewApps::AutomatedCleanup.new(options: options) timed('Docs Review Apps cleanup') do diff --git a/spec/controllers/concerns/kas_cookie_spec.rb b/spec/controllers/concerns/kas_cookie_spec.rb index e2ca19457ff..b1a951c663b 100644 --- a/spec/controllers/concerns/kas_cookie_spec.rb +++ b/spec/controllers/concerns/kas_cookie_spec.rb @@ -52,4 +52,71 @@ RSpec.describe KasCookie, feature_category: :kubernetes_management do end end end + + describe '#content_security_policy' do + let_it_be(:user) { create(:user) } + + controller(ApplicationController) do + include KasCookie + + def index + render json: {}, status: :ok + end + end + + before do + stub_config_setting(host: 'gitlab.example.com') + sign_in(user) + allow(::Gitlab::Kas).to receive(:enabled?).and_return(true) + allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url) + end + + subject(:kas_csp_connect_src) do + get :index + + request.env['action_dispatch.content_security_policy'].directives['connect-src'] + end + + context "when feature flag is disabled" do + let_it_be(:kas_tunnel_url) { 'ws://gitlab.example.com/-/k8s-proxy/' } + + before do + stub_feature_flags(kas_user_access: false) + end + + it 'does not add KAS url to connect-src directives' do + expect(kas_csp_connect_src).not_to include(::Gitlab::Kas.tunnel_url) + end + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(kas_user_access: true) + end + + context 'when KAS is on same domain as rails' do + let_it_be(:kas_tunnel_url) { 'ws://gitlab.example.com/-/k8s-proxy/' } + + it 'does not add KAS url to CSP connect-src directive' do + expect(kas_csp_connect_src).not_to include(::Gitlab::Kas.tunnel_url) + end + end + + context 'when KAS is on subdomain' do + let_it_be(:kas_tunnel_url) { 'ws://kas.gitlab.example.com/k8s-proxy/' } + + it 'adds KAS url to CSP connect-src directive' do + expect(kas_csp_connect_src).to include(::Gitlab::Kas.tunnel_url) + end + end + + context 'when KAS tunnel url is configured without trailing slash' do + let_it_be(:kas_tunnel_url) { 'ws://kas.gitlab.example.com/k8s-proxy' } + + it 'adds KAS url to CSP connect-src directive with trailing slash' do + expect(kas_csp_connect_src).to include("#{::Gitlab::Kas.tunnel_url}/") + end + end + end + end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 9e18089bb23..fd77d07705d 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -575,6 +575,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review it 'returns :failed' do expect(json_response).to eq('status' => 'failed') end + + context 'for logging' do + let(:expected_params) { { merge_action_status: 'failed' } } + let(:subject_proc) { proc { subject } } + + subject { post :merge, params: base_params } + + it_behaves_like 'storing arguments in the application context' + it_behaves_like 'not executing any extra queries for the application context' + end end context 'when the sha parameter does not match the source SHA' do @@ -585,6 +595,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review it 'returns :sha_mismatch' do expect(json_response).to eq('status' => 'sha_mismatch') end + + context 'for logging' do + let(:expected_params) { { merge_action_status: 'sha_mismatch' } } + let(:subject_proc) { proc { subject } } + + subject { post :merge, params: base_params.merge(sha: 'foo') } + + it_behaves_like 'storing arguments in the application context' + it_behaves_like 'not executing any extra queries for the application context' + end end context 'when the sha parameter matches the source SHA' do @@ -606,6 +626,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review merge_with_sha end + context 'for logging' do + let(:expected_params) { { merge_action_status: 'success' } } + let(:subject_proc) { proc { subject } } + + subject { merge_with_sha } + + it_behaves_like 'storing arguments in the application context' + it_behaves_like 'not executing any extra queries for the application context' + end + context 'when squash is passed as 1' do it 'updates the squash attribute on the MR to true' do merge_request.update!(squash: false) @@ -673,6 +703,16 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review merge_when_pipeline_succeeds end + context 'for logging' do + let(:expected_params) { { merge_action_status: 'merge_when_pipeline_succeeds' } } + let(:subject_proc) { proc { subject } } + + subject { merge_when_pipeline_succeeds } + + it_behaves_like 'storing arguments in the application context' + it_behaves_like 'not executing any extra queries for the application context' + end + context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do before do project.update_column(:only_allow_merge_if_pipeline_succeeds, true) diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index caeac6e3b92..f0fe0d56e93 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -88,6 +88,8 @@ FactoryBot.define do jira_issue_transition_automatic { false } jira_issue_transition_id { '56-1' } issues_enabled { false } + jira_issue_prefix { '' } + jira_issue_regex { '' } project_key { nil } vulnerabilities_enabled { false } vulnerabilities_issuetype { nil } diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js index 65bb68c5017..3b34b0ef672 100644 --- a/spec/frontend/lib/utils/chart_utils_spec.js +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -1,4 +1,8 @@ -import { firstAndLastY } from '~/lib/utils/chart_utils'; +import { firstAndLastY, getToolboxOptions } from '~/lib/utils/chart_utils'; +import { __ } from '~/locale'; +import * as iconUtils from '~/lib/utils/icon_utils'; + +jest.mock('~/lib/utils/icon_utils'); describe('Chart utils', () => { describe('firstAndLastY', () => { @@ -12,4 +16,53 @@ describe('Chart utils', () => { expect(firstAndLastY(data)).toEqual([1, 3]); }); }); + + describe('getToolboxOptions', () => { + describe('when icons are successfully fetched', () => { + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockImplementation((name) => + Promise.resolve(`${name}-svg-path-mock`), + ); + }); + + it('returns toolbox config', async () => { + await expect(getToolboxOptions()).resolves.toEqual({ + toolbox: { + feature: { + dataZoom: { + icon: { + zoom: 'path://marquee-selection-svg-path-mock', + back: 'path://redo-svg-path-mock', + }, + }, + restore: { + icon: 'path://repeat-svg-path-mock', + }, + saveAsImage: { + icon: 'path://download-svg-path-mock', + }, + }, + }, + }); + }); + }); + + describe('when icons are not successfully fetched', () => { + const error = new Error(); + + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockRejectedValue(error); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + it('returns empty object and calls `console.warn`', async () => { + await expect(getToolboxOptions()).resolves.toEqual({}); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + __('SVG could not be rendered correctly: '), + error, + ); + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js new file mode 100644 index 00000000000..7bde6cc4a8e --- /dev/null +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -0,0 +1,68 @@ +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +const mockConfirmAction = ({ confirmed }) => { + confirmAction.mockResolvedValueOnce(confirmed); +}; + +describe('containsSensitiveToken', () => { + describe('when message does not contain sensitive tokens', () => { + const nonSensitiveMessages = [ + 'This is a normal message', + '1234567890', + '!@#$%^&*()_+', + 'https://example.com', + ]; + + it.each(nonSensitiveMessages)('returns false for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(false); + }); + }); + + describe('when message contains sensitive tokens', () => { + const sensitiveMessages = [ + 'token: glpat-cgyKc1k_AsnEpmP-5fRL', + 'token: GlPat-abcdefghijklmnopqrstuvwxyz', + 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'https://example.com/feed?feed_token=123456789_abcdefghij', + 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + ]; + + it.each(sensitiveMessages)('returns true for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(true); + }); + }); +}); + +describe('confirmSensitiveAction', () => { + afterEach(() => { + confirmAction.mockReset(); + }); + + it('should call confirmAction with correct parameters', async () => { + const prompt = 'Are you sure you want to delete this item?'; + const expectedParams = { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }; + await confirmSensitiveAction(prompt); + + expect(confirmAction).toHaveBeenCalledWith(prompt, expectedParams); + }); + + it('should return true when confirmed is true', async () => { + mockConfirmAction({ confirmed: true }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(true); + }); + + it('should return false when confirmed is false', async () => { + mockConfirmAction({ confirmed: false }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(false); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 891b5c751fb..04143bb5b60 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -652,6 +652,37 @@ describe('issue_comment_form component', () => { }); }); + describe('check sensitive tokens', () => { + const sensitiveMessage = 'token: glpat-1234567890abcdefghij'; + const nonSensitiveMessage = 'text'; + + it('should not save note when it contains sensitive token', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: sensitiveMessage }, + }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + clickCommentButton(); + + expect(wrapper.vm.saveNote).not.toHaveBeenCalled(); + }); + + it('should save note it does not contain sensitive token', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: nonSensitiveMessage }, + }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + + clickCommentButton(); + + expect(wrapper.vm.saveNote).toHaveBeenCalled(); + }); + }); + describe('user is not logged in', () => { beforeEach(() => { mountComponent({ userData: null, noteableData: loggedOutnoteableData, mountFunction: mount }); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index e859d435f48..d1371ca0ef9 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -119,10 +119,21 @@ describe('Shortcuts', () => { }); describe('focusSearch', () => { - it('focuses the search bar', () => { - Shortcuts.focusSearch(createEvent('KeyboardEvent')); + describe('when super sidebar is NOT enabled', () => { + let originalGon; + beforeEach(() => { + originalGon = window.gon; + window.gon = { use_new_navigation: false }; + }); - expect(document.querySelector('#search').focus).toHaveBeenCalled(); + afterEach(() => { + window.gon = originalGon; + }); + + it('focuses the search bar', () => { + Shortcuts.focusSearch(createEvent('KeyboardEvent')); + expect(document.querySelector('#search').focus).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 53d81e3fcaf..32041638924 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -133,7 +133,7 @@ describe('SidebarDropdownWidget', () => { $apollo: { mutate: mutationPromise(), queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, attributesList: { loading: false }, ...queries, }, @@ -161,7 +161,9 @@ describe('SidebarDropdownWidget', () => { beforeEach(() => { createComponent({ data: { - currentAttribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' }, + issuable: { + attribute: { id: 'id', title: 'title', webUrl: 'webUrl', dueDate: '2021-09-09' }, + }, }, stubs: { GlDropdown, @@ -185,7 +187,7 @@ describe('SidebarDropdownWidget', () => { it('shows a loading spinner while fetching the current attribute', () => { createComponent({ queries: { - currentAttribute: { loading: true }, + issuable: { loading: true }, }, }); @@ -199,7 +201,7 @@ describe('SidebarDropdownWidget', () => { selectedTitle: 'Some milestone title', }, queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, }, }); @@ -224,10 +226,10 @@ describe('SidebarDropdownWidget', () => { createComponent({ data: { hasCurrentAttribute: true, - currentAttribute: null, + issuable: {}, }, queries: { - currentAttribute: { loading: false }, + issuable: { loading: false }, }, }); @@ -251,7 +253,9 @@ describe('SidebarDropdownWidget', () => { { id: '123', title: '123' }, { id: 'id', title: 'title' }, ], - currentAttribute: { id: '123' }, + issuable: { + attribute: { id: '123' }, + }, }, mutationPromise: mutationResp, }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js index e5ba1c63996..aac321bd8e0 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js @@ -1,31 +1,24 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; +import { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlLoadingIcon, + GlAvatar, + GlAlert, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import { - LARGE_AVATAR_PX, - SMALL_AVATAR_PX, -} from '~/super_sidebar/components/global_search/constants'; -import { - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - ISSUES_CATEGORY, - MERGE_REQUEST_CATEGORY, - RECENT_EPICS_CATEGORY, -} from '~/vue_shared/global_search/constants'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; + import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, - MOCK_SEARCH, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchAutocompleteItems', () => { +describe('GlobalSearchAutocompleteItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -36,30 +29,34 @@ describe('HeaderSearchAutocompleteItems', () => { }, getters: { autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchAutocompleteItems, { + wrapper = shallowMount(GlobalSearchAutocompleteItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => - findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); - const findDropdownItemSubTitles = () => - findDropdownItems() - .wrappers.filter((w) => w.findAll('span').length > 2) - .map((w) => w.findAll('span').at(2).text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemTitles = () => + findItems().wrappers.map((w) => w.find('[data-testid="autocomplete-item-name"]').text()); + const findItemSubTitles = () => + findItems() + .wrappers.map((w) => w.find('[data-testid="autocomplete-item-namespace"]')) + .filter((w) => w.exists()) + .map((w) => w.text()); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatars = () => wrapper.findAllComponents(GlAvatar).wrappers.map((w) => w.props('src')); const findGlAlert = () => wrapper.findComponent(GlAlert); describe('template', () => { @@ -73,7 +70,7 @@ describe('HeaderSearchAutocompleteItems', () => { }); it('does not render autocomplete options', () => { - expect(findDropdownItems()).toHaveLength(0); + expect(findItems()).toHaveLength(0); }); }); @@ -86,6 +83,7 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlAlert().exists()).toBe(true); }); }); + describe('when loading is false', () => { beforeEach(() => { createComponent({ loading: false }); @@ -95,143 +93,35 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlLoadingIcon().exists()).toBe(false); }); - describe('Dropdown items', () => { + describe('Search results items', () => { it('renders item for each option in autocomplete option', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); + expect(findItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); }); it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.text); + expect(findItemTitles()).toStrictEqual(expectedTitles); }); it('renders sub-titles correctly', () => { const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( - (o) => o.label, + (o) => o.namespace, ); - expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - item | showAvatar | avatarSize | searchContext | entityId | entityName - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} - `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { - describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { - beforeEach(() => { - createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] }); - }); - - it(`should${showAvatar ? '' : ' not'} render`, () => { - expect(findGlAvatar().exists()).toBe(showAvatar); - }); - - it(`should set avatarSize to ${avatarSize}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); - }); - - it(`should set avatar entityId to ${entityId}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); - }); - - it(`should set avatar entityName to ${entityName}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( - entityName, - ); - }); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); + expect(findItemSubTitles()).toStrictEqual(expectedSubTitles); }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it('renders links correctly', () => { + const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); - }); - }); - describe.each` - search | items | dividerCount - ${null} | ${[]} | ${0} - ${''} | ${[]} | ${0} - ${'1'} | ${[]} | ${0} - ${')'} | ${[]} | ${0} - ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} - ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} - ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - createComponent( - { search }, - { - autocompleteGroupedSearchOptions: () => items, - }, - {}, + it('renders avatars', () => { + const expectedAvatars = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.avatar_url).filter( + Boolean, ); + expect(findAvatars()).toStrictEqual(expectedAvatars); }); - - it(`component should have ${dividerCount} dividers`, () => { - expect(findGlDropdownDividers()).toHaveLength(dividerCount); - }); - }); - }); - }); - - describe('watchers', () => { - describe('currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - - it('when focused changes to existing element calls scroll into view on the newly focused element', async () => { - const focusedElement = findFirstDropdownItem().element; - const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView'); - - wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] }); - - await nextTick(); - - expect(scrollSpy).toHaveBeenCalledWith(false); - scrollSpy.mockRestore(); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js index 132f8e60598..52e9aa52c14 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js @@ -1,13 +1,13 @@ -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchDefaultItems', () => { +describe('GlobalSearchDefaultItems', () => { let wrapper; const createComponent = (initialState, props) => { @@ -21,19 +21,19 @@ describe('HeaderSearchDefaultItems', () => { }, }); - wrapper = shallowMount(HeaderSearchDefaultItems, { + wrapper = shallowMountExtended(GlobalSearchDefaultItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + }, }); }; - const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsData = () => findItems().wrappers.map((w) => w.props('item')); describe('template', () => { describe('Dropdown items', () => { @@ -42,26 +42,20 @@ describe('HeaderSearchDefaultItems', () => { }); it('renders item for each option in defaultSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); - }); - - it('renders titles correctly', () => { - const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + expect(findItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); }); - it('renders links correctly', () => { - const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('provides the `item` prop to the `GlDisclosureDropdownItem` component', () => { + expect(findItemsData()).toStrictEqual(MOCK_DEFAULT_SEARCH_OPTIONS); }); }); describe.each` - group | project | dropdownTitle + group | project | groupHeader ${null} | ${null} | ${'All GitLab'} ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} - `('Dropdown Header', ({ group, project, dropdownTitle }) => { + `('Group Header', ({ group, project, groupHeader }) => { describe(`when group is ${group?.name} and project is ${project?.name}`, () => { beforeEach(() => { createComponent({ @@ -72,29 +66,8 @@ describe('HeaderSearchDefaultItems', () => { }); }); - it(`should render as ${dropdownTitle}`, () => { - expect(findDropdownHeader().text()).toBe(dropdownTitle); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it(`should render as ${groupHeader}`, () => { + expect(wrapper.text()).toContain(groupHeader); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js index fa91ef43ced..4976f3be4cd 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js @@ -1,21 +1,21 @@ -import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; import { truncate } from '~/lib/utils/text_utility'; import { SCOPE_TOKEN_MAX_LENGTH } from '~/super_sidebar/components/global_search/constants'; import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants'; import { MOCK_SEARCH, - MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_GROUP, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchScopedItems', () => { +describe('GlobalSearchScopedItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -25,96 +25,67 @@ describe('HeaderSearchScopedItems', () => { ...initialState, }, getters: { - scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + scopedSearchGroup: () => MOCK_SCOPED_SEARCH_GROUP, autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchScopedItems, { + wrapper = shallowMount(GlobalSearchScopedItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsText = () => findItems().wrappers.map((w) => trimText(w.text())); const findScopeTokens = () => wrapper.findAllComponents(GlToken); const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text())); const findScopeTokensIcons = () => findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon)); - const findDropdownItemAriaLabels = () => - findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - - describe('template', () => { - describe('Dropdown items', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders item for each option in scopedSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); - }); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); - it('renders titles correctly', () => { - findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH)); - }); - - it('renders scope names correctly', () => { - const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH), - ); + describe('Search results scoped items', () => { + beforeEach(() => { + createComponent(); + }); - expect(findScopeTokensText()).toStrictEqual(expectedTitles); - }); + it('renders item for each item in scopedSearchGroup', () => { + expect(findItems()).toHaveLength(MOCK_SCOPED_SEARCH_GROUP.items.length); + }); - it('renders scope icons correctly', () => { - findScopeTokensIcons().forEach((icon, i) => { - const w = icon.wrappers[0]; - expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon); - }); - }); + it('renders titles correctly', () => { + findItemsText().forEach((title) => expect(title).toContain(MOCK_SEARCH)); + }); - it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { - expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); - }); + it('renders scope names correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => + truncate(trimText(`in ${o.scope || o.description}`), SCOPE_TOKEN_MAX_LENGTH), + ); - it('renders aria-labels correctly', () => { - const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`), - ); - expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); - }); + expect(findScopeTokensText()).toStrictEqual(expectedTitles); + }); - it('renders links correctly', () => { - const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('renders scope icons correctly', () => { + findScopeTokensIcons().forEach((icon, i) => { + const w = icon.wrappers[0]; + expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_GROUP.items[i].icon); }); }); - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => { - beforeEach(() => { - createComponent({}, {}, { currentFocusedOption }); - }); - - it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => { - expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused); - }); + it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { + expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); + }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index 0dcfc448125..eb8801f68c6 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -1,30 +1,25 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking } from 'helpers/tracking_helper'; import { s__, sprintf } from '~/locale'; -import HeaderSearchApp from '~/super_sidebar/components/global_search/components/global_search.vue'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, - SEARCH_BOX_INDEX, ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP, SCOPE_TOKEN_MAX_LENGTH, IS_SEARCHING, - IS_NOT_FOCUSED, - IS_FOCUSED, SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '~/super_sidebar/components/global_search/constants'; -import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; -import { ENTER_KEY } from '~/lib/utils/keys'; -import { visitUrl } from '~/lib/utils/url_utility'; import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { MOCK_SEARCH, MOCK_SEARCH_QUERY, @@ -32,6 +27,8 @@ import { MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SEARCH_CONTEXT_FULL, + MOCK_PROJECT, + MOCK_GROUP, } from '../mock_data'; Vue.use(Vuex); @@ -40,7 +37,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), })); -describe('HeaderSearchApp', () => { +describe('GlobalSearchModal', () => { let wrapper; const actionSpies = { @@ -49,21 +46,31 @@ describe('HeaderSearchApp', () => { clearAutocomplete: jest.fn(), }; - const createComponent = (initialState, mockGetters) => { + const deafaultMockState = { + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }; + + const createComponent = (initialState, mockGetters, stubs) => { const store = new Vuex.Store({ state: { + ...deafaultMockState, ...initialState, }, actions: actionSpies, getters: { searchQuery: () => MOCK_SEARCH_QUERY, searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMountExtended(HeaderSearchApp, { + wrapper = shallowMountExtended(GlobalSearchModal, { store, + stubs, }); }; @@ -80,16 +87,13 @@ describe('HeaderSearchApp', () => { ); }; - const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); - const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGlobalSearchForm = () => wrapper.findByTestId('global-search-form'); + const findGlobalSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findScopeToken = () => wrapper.findComponent(GlToken); - const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper'); - const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); - const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); - const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); - const findHeaderSearchAutocompleteItems = () => - wrapper.findComponent(HeaderSearchAutocompleteItems); - const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation); + const findGlobalSearchDefaultItems = () => wrapper.findComponent(GlobalSearchDefaultItems); + const findGlobalSearchScopedItems = () => wrapper.findComponent(GlobalSearchScopedItems); + const findGlobalSearchAutocompleteItems = () => + wrapper.findComponent(GlobalSearchAutocompleteItems); const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); @@ -99,16 +103,8 @@ describe('HeaderSearchApp', () => { createComponent(); }); - it('Header Search Input', () => { - expect(findHeaderSearchInput().exists()).toBe(true); - }); - - it('Header Search Input KBD hint', () => { - expect(findHeaderSearchInputKBD().exists()).toBe(true); - expect(findHeaderSearchInputKBD().text()).toContain('/'); - expect(findHeaderSearchInputKBD().attributes('title')).toContain( - 'Use the shortcut key <kbd>/</kbd> to start a search', - ); + it('Global Search Input', () => { + expect(findGlobalSearchInput().exists()).toBe(true); }); it('Search Input Description', () => { @@ -121,26 +117,6 @@ describe('HeaderSearchApp', () => { }); describe.each` - showDropdown | username | showSearchDropdown - ${false} | ${null} | ${false} - ${false} | ${MOCK_USERNAME} | ${false} - ${true} | ${null} | ${false} - ${true} | ${MOCK_USERNAME} | ${true} - `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { - describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); - }); - - it(`should${showSearchDropdown ? '' : ' not'} render`, () => { - expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); - }); - }); - }); - - describe.each` search | showDefault | showScoped | showAutocomplete ${null} | ${true} | ${false} | ${false} ${''} | ${true} | ${false} | ${false} @@ -148,71 +124,40 @@ describe('HeaderSearchApp', () => { ${'te'} | ${false} | ${false} | ${true} ${'tes'} | ${false} | ${true} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { + `('Global Search Result Items', ({ search, showDefault, showScoped, showAutocomplete }) => { describe(`when search is ${search}`, () => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search }, {}); - findHeaderSearchInput().vm.$emit('click'); - }); - - it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { - expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); - }); - - it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { - expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); - }); - - it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { - expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); - }); - - it(`should render the Dropdown Navigation Component`, () => { - expect(findDropdownKeyboardNavigation().exists()).toBe(true); + findGlobalSearchInput().vm.$emit('click'); }); - it(`should close the dropdown when press escape key`, async () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); - await nextTick(); - expect(findHeaderSearchDropdown().exists()).toBe(false); - expect(wrapper.emitted().expandSearchBar.length).toBe(1); + it(`should${showDefault ? '' : ' not'} render the Default Items`, () => { + expect(findGlobalSearchDefaultItems().exists()).toBe(showDefault); }); - }); - }); - describe.each` - username | showDropdown | expectedDesc - ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - `('Search Input Description', ({ username, showDropdown, expectedDesc }) => { - describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => { - beforeEach(() => { - window.gon.current_username = username; - createComponent(); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); + it(`should${showScoped ? '' : ' not'} render the Scoped Items`, () => { + expect(findGlobalSearchScopedItems().exists()).toBe(showScoped); }); - it(`sets description to ${expectedDesc}`, () => { - expect(findSearchInputDescription().text()).toBe(expectedDesc); + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Items`, () => { + expect(findGlobalSearchAutocompleteItems().exists()).toBe(showAutocomplete); }); }); }); describe.each` - username | showDropdown | search | loading | searchOptions | expectedDesc - ${null} | ${true} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''} - ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} - ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING} + username | search | loading | searchOptions | expectedDesc + ${null} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} + ${MOCK_USERNAME} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} `( 'Search Results Description', - ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { - describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { + ({ username, search, loading, searchOptions, expectedDesc }) => { + describe(`search is "${search}" and loading is ${loading}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent( @@ -224,7 +169,6 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`sets description to ${expectedDesc}`, () => { @@ -253,7 +197,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ @@ -274,42 +218,31 @@ describe('HeaderSearchApp', () => { describe('form', () => { describe.each` - searchContext | search | searchOptions | isFocused - ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false} - ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} - ${null} | ${null} | ${[]} | ${true} - `('wrapper', ({ searchContext, search, searchOptions, isFocused }) => { + searchContext | search | searchOptions + ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${[]} + `('wrapper', ({ searchContext, search, searchOptions }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); - if (isFocused) { - findHeaderSearchInput().vm.$emit('click'); - } }); const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => { if (isSearching) { - expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING); + expect(findGlobalSearchForm().classes()).toContain(IS_SEARCHING); return; } if (!isSearching) { - expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING); + expect(findGlobalSearchForm().classes()).not.toContain(IS_SEARCHING); } }); - - it(`classes ${isSearching ? 'contain' : 'do not contain'} "${ - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED - }"`, () => { - expect(findHeaderSearchForm().classes()).toContain( - isFocused ? IS_FOCUSED : IS_NOT_FOCUSED, - ); - }); }); }); @@ -328,7 +261,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`icon for data set type "${searchOptions[0]?.html_id}" ${ @@ -350,56 +283,15 @@ describe('HeaderSearchApp', () => { describe('events', () => { beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; createComponent(); + window.gon.current_username = MOCK_USERNAME; }); - describe('Header Search Input', () => { - describe('when dropdown is closed', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('onFocus opens dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('focus'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - - it('onClick opens dropdown and triggers snowplow event', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(false); - findHeaderSearchInput().vm.$emit('click'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(true); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { - label: 'global_search', - property: 'navigation_top', - }); - }); - - it('onClick followed by onFocus only triggers a single snowplow event', async () => { - findHeaderSearchInput().vm.$emit('click'); - findHeaderSearchInput().vm.$emit('focus'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - }); - }); - + describe('Global Search Input', () => { describe('onInput', () => { describe('when search has text', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + findGlobalSearchInput().vm.$emit('input', MOCK_SEARCH); }); it('calls setSearch with search term', () => { @@ -417,7 +309,7 @@ describe('HeaderSearchApp', () => { describe('when search is emptied', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', ''); + findGlobalSearchInput().vm.$emit('input', ''); }); it('calls setSearch with empty term', () => { @@ -433,83 +325,29 @@ describe('HeaderSearchApp', () => { }); }); }); - }); - - describe('Dropdown Keyboard Navigation', () => { - beforeEach(() => { - findHeaderSearchInput().vm.$emit('click'); - }); - - it('closes dropdown when @tab is emitted', async () => { - expect(findHeaderSearchDropdown().exists()).toBe(true); - findDropdownKeyboardNavigation().vm.$emit('tab'); - - await nextTick(); - - expect(findHeaderSearchDropdown().exists()).toBe(false); - }); - }); - }); - - describe('computed', () => { - describe.each` - MOCK_INDEX | search - ${1} | ${null} - ${SEARCH_BOX_INDEX} | ${'test'} - ${2} | ${'test1'} - `('currentFocusedOption', ({ MOCK_INDEX, search }) => { - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); - findHeaderSearchInput().vm.$emit('click'); - }); - - it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); - expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]); - }); - }); - }); - - describe('Submitting a search', () => { - describe('with no currentFocusedOption', () => { - beforeEach(() => { - createComponent(); - }); - it('onKey-enter submits a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); - - describe('with less than min characters and no dropdown results', () => { - beforeEach(() => { - createComponent({ search: 'x' }); - }); - - it('onKey-enter will NOT submit a search', () => { - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + describe('Submitting a search', () => { + beforeEach(() => { + createComponent(); + }); - expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); + it('onKey-enter submits a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - describe('with currentFocusedOption', () => { - const MOCK_INDEX = 1; + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - findHeaderSearchInput().vm.$emit('click'); - }); + describe('with less than min characters', () => { + beforeEach(() => { + createComponent({ search: 'x' }); + }); - it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); + it('onKey-enter will NOT submit a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); + expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/mock_data.js b/spec/frontend/super_sidebar/components/global_search/mock_data.js index 58e578e4c4c..0884fce567c 100644 --- a/spec/frontend/super_sidebar/components/global_search/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js @@ -3,6 +3,7 @@ import { ICON_GROUP, ICON_SUBGROUP, } from '~/super_sidebar/components/global_search/constants'; + import { PROJECTS_CATEGORY, GROUPS_CATEGORY, @@ -77,90 +78,107 @@ export const MOCK_SEARCH_CONTEXT_FULL = { export const MOCK_DEFAULT_SEARCH_OPTIONS = [ { - html_id: 'default-issues-assigned', - title: MSG_ISSUES_ASSIGNED_TO_ME, - url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { - html_id: 'default-issues-created', - title: MSG_ISSUES_IVE_CREATED, - url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-assigned', - title: MSG_MR_ASSIGNED_TO_ME, - url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-reviewer', - title: MSG_MR_IM_REVIEWER, - url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, + text: MSG_MR_IM_REVIEWER, + href: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`, }, { - html_id: 'default-mrs-created', - title: MSG_MR_IVE_CREATED, - url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, + text: MSG_MR_IVE_CREATED, + href: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS = [ +export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: MOCK_PROJECT.path, - }, - { - html_id: 'scoped-in-project-long', - scope: MOCK_PROJECT_LONG.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT_LONG.path, + href: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, - url: MOCK_GROUP.path, - }, - { - html_id: 'scoped-in-subgroup', - scope: MOCK_SUBGROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_SUBGROUP, - url: MOCK_SUBGROUP.path, + href: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: MOCK_ALL_PATH, + href: MOCK_ALL_PATH, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ +export const MOCK_SCOPED_SEARCH_OPTIONS = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, url: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-project-long', + scope: MOCK_PROJECT_LONG.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + url: MOCK_PROJECT_LONG.path, + }, + { + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, url: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-subgroup', + scope: MOCK_SUBGROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_SUBGROUP, + url: MOCK_SUBGROUP.path, + }, + { + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: MOCK_ALL_PATH, }, ]; +export const MOCK_SCOPED_SEARCH_GROUP = { + items: [ + { + text: 'scoped-in-project', + scope: MOCK_PROJECT.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + href: MOCK_PROJECT.path, + }, + { + text: 'scoped-in-group', + scope: MOCK_GROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_GROUP, + href: MOCK_GROUP.path, + }, + { + text: 'scoped-in-all', + description: MSG_IN_ALL_GITLAB, + href: MOCK_ALL_PATH, + }, + ], +}; + export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', @@ -168,8 +186,10 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { + avatar_url: '/groups/avatar/1/avatar.png', category: 'Groups', id: 1, label: 'Gitlab Org / MockGroup1', @@ -177,6 +197,7 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ url: 'group/1', }, { + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', id: 2, label: 'Gitlab Org / MockProject2', @@ -193,31 +214,30 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ export const MOCK_AUTOCOMPLETE_OPTIONS = [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', id: 1, label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', url: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', id: 2, label: 'Gitlab Org / MockProject2', value: 'MockProject2', url: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', }, { category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', url: 'help/gitlab', }, @@ -225,51 +245,64 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ { - category: 'Groups', - data: [ + name: 'Groups', + items: [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', - id: 1, label: 'Gitlab Org / MockGroup1', + namespace: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, ], }, { - category: 'Projects', - data: [ + name: 'Projects', + items: [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', - id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', value: 'MockProject1', - url: 'project/1', + text: 'MockProject1', + href: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockProject1', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', - id: 2, - label: 'Gitlab Org / MockProject2', value: 'MockProject2', - url: 'project/2', + label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', + href: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', + avatar_size: 32, + entity_id: 2, + entity_name: 'MockProject2', }, ], }, { - category: 'Help', - data: [ + name: 'Help', + items: [ { category: 'Help', - html_id: 'autocomplete-Help-3', - label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', + avatar_size: 16, + entity_name: 'GitLab Help', }, ], }, @@ -278,33 +311,50 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + namespace: 'Gitlab Org / MockGroup1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/1/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-0', + entity_id: 1, + entity_name: 'MockProject1', + href: 'project/1', id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', + text: 'MockProject1', value: 'MockProject1', - url: 'project/1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-2', + entity_id: 2, + entity_name: 'MockProject2', + href: 'project/2', id: 2, label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', value: 'MockProject2', - url: 'project/2', }, { + avatar_size: 16, + entity_name: 'GitLab Help', category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', }, ]; @@ -315,14 +365,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ { html_id: 'autocomplete-Help-1', category: 'Help', + text: 'Rake Tasks Help', label: 'Rake Tasks Help', - url: '/help/raketasks/index', + href: '/help/raketasks/index', }, { html_id: 'autocomplete-Help-2', category: 'Help', + text: 'System Hooks Help', label: 'System Hooks Help', - url: '/help/system_hooks/system_hooks', + href: '/help/system_hooks/system_hooks', }, ], }, diff --git a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js index c87b4513309..f6d8e1f26eb 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js @@ -16,9 +16,7 @@ import { MOCK_ISSUE_PATH, } from '../mock_data'; -jest.mock('~/alert'); - -describe('Header Search Store Actions', () => { +describe('Global Search Store Actions', () => { let state; let mock; diff --git a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js index dca96da01a7..68583d04b31 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js @@ -9,7 +9,7 @@ import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS_DEF, + MOCK_SCOPED_SEARCH_GROUP, MOCK_PROJECT, MOCK_GROUP, MOCK_ALL_PATH, @@ -17,9 +17,10 @@ import { MOCK_AUTOCOMPLETE_OPTIONS, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS_DEF, } from '../mock_data'; -describe('Header Search Store Getters', () => { +describe('Global Search Store Getters', () => { let state; const createState = (initialState) => { @@ -288,7 +289,7 @@ describe('Header Search Store Getters', () => { describe.each` search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray - ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} + ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_GROUP} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS} ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)} diff --git a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js index d2dc484e825..4d275cf86c7 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js @@ -29,7 +29,7 @@ describe('Header Search Store Mutations', () => { mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES); expect(state.loading).toBe(false); - expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); + expect(state.autocompleteOptions).toEqual(MOCK_AUTOCOMPLETE_OPTIONS); expect(state.autocompleteError).toBe(false); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/utils_spec.js new file mode 100644 index 00000000000..3b12063e733 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/utils_spec.js @@ -0,0 +1,60 @@ +import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; +import { + LARGE_AVATAR_PX, + SMALL_AVATAR_PX, +} from '~/super_sidebar/components/global_search/constants'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; + +describe('getFormattedItem', () => { + describe.each` + item | avatarSize | searchContext | entityId | entityName + ${{ category: PROJECTS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 29 } }} | ${29} | ${'project1'} + ${{ category: GROUPS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 12 } }} | ${12} | ${'project1'} + ${{ category: 'Help', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: 'Settings', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: GROUPS_CATEGORY, value: 'group1', label: 'Group 1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${1} | ${'group1'} + ${{ category: PROJECTS_CATEGORY, value: 'group2', label: 'Group2' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${2} | ${'group2'} + ${{ category: ISSUES_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${3} | ${'test3'} + ${{ category: MERGE_REQUEST_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${4} | ${'test4'} + ${{ category: RECENT_EPICS_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${5} | ${'test5'} + ${{ category: GROUPS_CATEGORY, group_id: 6, group_name: 'test6' }} | ${LARGE_AVATAR_PX} | ${null} | ${6} | ${'test6'} + ${{ category: PROJECTS_CATEGORY, project_id: 7, project_name: 'test7' }} | ${LARGE_AVATAR_PX} | ${null} | ${7} | ${'test7'} + ${{ category: ISSUES_CATEGORY, project_id: 8, project_name: 'test8' }} | ${SMALL_AVATAR_PX} | ${null} | ${8} | ${'test8'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 9, project_name: 'test9' }} | ${SMALL_AVATAR_PX} | ${null} | ${9} | ${'test9'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 10, group_name: 'test10' }} | ${SMALL_AVATAR_PX} | ${null} | ${10} | ${'test10'} + ${{ category: GROUPS_CATEGORY, group_id: 11, group_name: 'test11' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${11} | ${'test11'} + ${{ category: PROJECTS_CATEGORY, project_id: 12, project_name: 'test12' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${12} | ${'test12'} + ${{ category: ISSUES_CATEGORY, project_id: 13, project_name: 'test13' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${13} | ${'test13'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 14, project_name: 'test14' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${14} | ${'test14'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 15, group_name: 'test15' }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${15} | ${'test15'} + `('formats the item', ({ item, avatarSize, searchContext, entityId, entityName }) => { + describe(`when item is ${JSON.stringify(item)}`, () => { + let formattedItem; + beforeEach(() => { + formattedItem = getFormattedItem(item, searchContext); + }); + + it(`should set text to ${item.value || item.label}`, () => { + expect(formattedItem.text).toBe(item.value || item.label); + }); + + it(`should set avatarSize to ${avatarSize}`, () => { + expect(formattedItem.avatar_size).toBe(avatarSize); + }); + + it(`should set avatar entityId to ${entityId}`, () => { + expect(formattedItem.entity_id).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(formattedItem.entity_name).toBe(entityName); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index ae15dd55644..48e62c3564d 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -1,11 +1,17 @@ import { GlBadge } from '@gitlab/ui'; +import Vuex from 'vuex'; +import Vue from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; +import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; import { sidebarData } from '../mock_data'; +import { MOCK_DEFAULT_SEARCH_OPTIONS } from './global_search/mock_data'; describe('UserBar component', () => { let wrapper; @@ -14,7 +20,16 @@ describe('UserBar component', () => { const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo'); + const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button'); + const findSearchModal = () => wrapper.findComponent(SearchModal); + Vue.use(Vuex); + + const store = new Vuex.Store({ + getters: { + searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + }, + }); const createWrapper = (extraSidebarData = {}) => { wrapper = shallowMountExtended(UserBar, { propsData: { @@ -24,6 +39,10 @@ describe('UserBar component', () => { rootPath: '/', toggleNewNavEndpoint: '/-/profile/preferences', }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + store, }); }; @@ -81,4 +100,24 @@ describe('UserBar component', () => { }); }); }); + + describe('Search', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('should render search button', () => { + expect(findSearchButton().exists()).toBe(true); + }); + + it('search button should have tooltip', () => { + const tooltip = getBinding(findSearchButton().element, 'gl-tooltip'); + expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`); + }); + + it('should render search modal', async () => { + expect(findSearchModal().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index b6231e03722..9805adfa8ae 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -93,6 +93,14 @@ describe('UserMenu component', () => { expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true); }); + it('should close the dropdown when status modal opened', () => { + setItem({ can_update: true }); + wrapper.vm.$refs.userDropdown.close = jest.fn(); + expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled(); + item.vm.$emit('action'); + expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled(); + }); + describe('renders correct label', () => { it.each` busy | customized | label diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index 8c70693465f..d86cae199ab 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -87,6 +87,9 @@ export const sidebarData = { gitlab_com_and_canary: false, canary_toggle_com_url: 'https://next.gitlab.com', context_switcher_links: [], + search: { + search_path: '/search', + }, }; export const userMenuMockStatus = { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js index e693ccfb156..07efb1c5ac8 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js @@ -1,4 +1,4 @@ -import { GlLabel, GlAvatarsInline } from '@gitlab/ui'; +import { GlAvatarsInline } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -8,10 +8,9 @@ import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/w import { workItemObjectiveMetadataWidgets } from '../../mock_data'; describe('WorkItemLinkChildMetadata', () => { - const { MILESTONE, ASSIGNEES, LABELS } = workItemObjectiveMetadataWidgets; + const { MILESTONE, ASSIGNEES } = workItemObjectiveMetadataWidgets; const mockMilestone = MILESTONE.milestone; const mockAssignees = ASSIGNEES.assignees.nodes; - const mockLabels = LABELS.labels.nodes; let wrapper; const createComponent = ({ metadataWidgets = workItemObjectiveMetadataWidgets } = {}) => { @@ -53,18 +52,4 @@ describe('WorkItemLinkChildMetadata', () => { badgeSrOnlyText: '', }); }); - - it('renders labels', () => { - const labels = wrapper.findAllComponents(GlLabel); - const mockLabel = mockLabels[0]; - - expect(labels).toHaveLength(mockLabels.length); - expect(labels.at(0).props()).toMatchObject({ - title: mockLabel.title, - backgroundColor: mockLabel.color, - description: mockLabel.description, - scoped: false, - }); - expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped - }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index 721436e217e..4fef5c0b91e 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlLabel, GlIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -37,6 +37,8 @@ describe('WorkItemLinkChild', () => { const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; let wrapper; let getWorkItemTreeQueryHandler; + const { LABELS } = workItemObjectiveMetadataWidgets; + const mockLabels = LABELS.labels.nodes; Vue.use(VueApollo); @@ -165,8 +167,6 @@ describe('WorkItemLinkChild', () => { expect(metadataEl.props()).toMatchObject({ metadataWidgets: workItemObjectiveMetadataWidgets, }); - - expect(wrapper.find('[data-testid="links-child"]').classes()).toContain('gl-py-3'); }); it('does not render item metadata component when item has no metadata present', () => { @@ -176,8 +176,20 @@ describe('WorkItemLinkChild', () => { }); expect(findMetadataComponent().exists()).toBe(false); + }); - expect(wrapper.find('[data-testid="links-child"]').classes()).toContain('gl-py-0'); + it('renders labels', () => { + const labels = wrapper.findAllComponents(GlLabel); + const mockLabel = mockLabels[0]; + + expect(labels).toHaveLength(mockLabels.length); + expect(labels.at(0).props()).toMatchObject({ + title: mockLabel.title, + backgroundColor: mockLabel.color, + description: mockLabel.description, + scoped: false, + }); + expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped }); }); diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index ea48246fabe..bdfc45811df 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -73,6 +73,7 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do before do allow(helper).to receive(:current_user) { user } allow(helper).to receive(:can?).and_return(true) + allow(helper).to receive(:header_search_context).and_return({ some: "search data" }) allow(panel).to receive(:super_sidebar_menu_items).and_return(nil) allow(panel).to receive(:super_sidebar_context_header).and_return(nil) allow(user).to receive(:assigned_open_issues_count).and_return(1) @@ -126,7 +127,14 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do gitlab_version_check: helper.gitlab_version_check, gitlab_com_but_not_canary: Gitlab.com_but_not_canary?, gitlab_com_and_canary: Gitlab.com_and_canary?, - canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url + canary_toggle_com_url: Gitlab::Saas.canary_toggle_com_url, + search: { + search_path: search_path, + issues_path: issues_dashboard_path, + mr_path: merge_requests_dashboard_path, + autocomplete_path: search_autocomplete_path, + search_context: helper.header_search_context + } }) end diff --git a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb index 42fc441b868..48339d46153 100644 --- a/spec/lib/atlassian/jira_issue_key_extractor_spec.rb +++ b/spec/lib/atlassian/jira_issue_key_extractor_spec.rb @@ -33,5 +33,13 @@ RSpec.describe Atlassian::JiraIssueKeyExtractor, feature_category: :integrations is_expected.to contain_exactly('TEST-01') end end + + context 'with custom_regex' do + subject { described_class.new('TEST-01 some A-100', custom_regex: /(?<issue>[B-Z]+-\d+)/).issue_keys } + + it 'returns all valid Jira issue keys' do + is_expected.to contain_exactly('TEST-01') + end + end end end diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index ffb651fe23c..b40829d72a0 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -178,53 +178,6 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end end - context 'when KAS is configured' do - before do - stub_config_setting(host: 'gitlab.example.com') - allow(::Gitlab::Kas).to receive(:enabled?).and_return true - end - - context 'when user access feature flag is disabled' do - before do - stub_feature_flags(kas_user_access: false) - end - - it 'does not add KAS url to CSP' do - expect(directives['connect_src']).not_to eq("'self' ws://gitlab.example.com #{::Gitlab::Kas.tunnel_url}") - end - end - - context 'when user access feature flag is enabled' do - before do - stub_feature_flags(kas_user_access: true) - end - - context 'when KAS is on same domain as rails' do - let_it_be(:kas_tunnel_url) { "ws://gitlab.example.com/-/k8s-proxy/" } - - before do - allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url) - end - - it 'does not add KAS url to CSP' do - expect(directives['connect_src']).not_to eq("'self' ws://gitlab.example.com #{::Gitlab::Kas.tunnel_url}") - end - end - - context 'when KAS is on subdomain' do - let_it_be(:kas_tunnel_url) { "ws://kas.gitlab.example.com/k8s-proxy/" } - - before do - allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url) - end - - it 'does add KAS url to CSP' do - expect(directives['connect_src']).to eq("'self' ws://gitlab.example.com #{kas_tunnel_url}") - end - end - end - end - context 'when CUSTOMER_PORTAL_URL is set' do let(:customer_portal_url) { 'https://customers.example.com' } diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 3f6528558b1..982a06152a9 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -120,157 +120,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers, feature_category: :database d end end - describe '#create_table_with_constraints' do - let(:table_name) { :test_table } - let(:column_attributes) do - [ - { name: 'id', sql_type: 'bigint', null: false, default: nil }, - { name: 'created_at', sql_type: 'timestamp with time zone', null: false, default: nil }, - { name: 'updated_at', sql_type: 'timestamp with time zone', null: false, default: nil }, - { name: 'some_id', sql_type: 'integer', null: false, default: nil }, - { name: 'active', sql_type: 'boolean', null: false, default: 'true' }, - { name: 'name', sql_type: 'text', null: true, default: nil } - ] - end - - before do - allow(model).to receive(:transaction_open?).and_return(true) - end - - context 'when no check constraints are defined' do - it 'creates the table as expected' do - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - end - - expect_table_columns_to_match(column_attributes, table_name) - end - end - - context 'when check constraints are defined' do - context 'when the text_limit is explicity named' do - it 'creates the table as expected' do - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - - t.text_limit :name, 255, name: 'check_name_length' - t.check_constraint :some_id_is_positive, 'some_id > 0' - end - - expect_table_columns_to_match(column_attributes, table_name) - - expect_check_constraint(table_name, 'check_name_length', 'char_length(name) <= 255') - expect_check_constraint(table_name, 'some_id_is_positive', 'some_id > 0') - end - end - - context 'when the text_limit is not named' do - it 'creates the table as expected, naming the text limit' do - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - - t.text_limit :name, 255 - t.check_constraint :some_id_is_positive, 'some_id > 0' - end - - expect_table_columns_to_match(column_attributes, table_name) - - expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255') - expect_check_constraint(table_name, 'some_id_is_positive', 'some_id > 0') - end - end - - it 'runs the change within a with_lock_retries' do - expect(model).to receive(:with_lock_retries).ordered.and_yield - expect(model).to receive(:create_table).ordered.and_call_original - expect(model).to receive(:execute).with(<<~SQL).ordered - ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255) - SQL - - model.create_table_with_constraints table_name do |t| - t.text :name - t.text_limit :name, 255 - end - end - - context 'when with_lock_retries re-runs the block' do - it 'only creates constraint for unique definitions' do - expected_sql = <<~SQL - ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255) - SQL - - expect(model).to receive(:create_table).twice.and_call_original - - expect(model).to receive(:execute).with(expected_sql).and_raise(ActiveRecord::LockWaitTimeout) - expect(model).to receive(:execute).with(expected_sql).and_call_original - - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - - t.text_limit :name, 255 - end - - expect_table_columns_to_match(column_attributes, table_name) - - expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255') - end - end - - context 'when constraints are given invalid names' do - let(:expected_max_length) { described_class::MAX_IDENTIFIER_NAME_LENGTH } - let(:expected_error_message) { "The maximum allowed constraint name is #{expected_max_length} characters" } - - context 'when the explicit text limit name is not valid' do - it 'raises an error' do - too_long_length = expected_max_length + 1 - - expect do - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - - t.text_limit :name, 255, name: ('a' * too_long_length) - t.check_constraint :some_id_is_positive, 'some_id > 0' - end - end.to raise_error(expected_error_message) - end - end - - context 'when a check constraint name is not valid' do - it 'raises an error' do - too_long_length = expected_max_length + 1 - - expect do - model.create_table_with_constraints table_name do |t| - t.timestamps_with_timezone - t.integer :some_id, null: false - t.boolean :active, null: false, default: true - t.text :name - - t.text_limit :name, 255 - t.check_constraint ('a' * too_long_length), 'some_id > 0' - end - end.to raise_error(expected_error_message) - end - end - end - end - end - describe '#add_concurrent_index' do context 'outside a transaction' do before do diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 4d608c07736..0c6a832a730 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -301,7 +301,7 @@ RSpec.describe Gitlab::ReferenceExtractor do describe 'referables prefixes' do def prefixes - described_class::REFERABLES.each_with_object({}) do |referable, result| + described_class.referrables.each_with_object({}) do |referable, result| class_name = referable.to_s.camelize klass = class_name.constantize if Object.const_defined?(class_name) @@ -314,7 +314,7 @@ RSpec.describe Gitlab::ReferenceExtractor do end it 'returns all supported prefixes' do - expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & [vulnerability: *iteration:)) + expect(prefixes.keys.uniq).to include(*%w(@ # ~ % ! $ & [vulnerability:)) end it 'does not allow one prefix for multiple referables if not allowed specificly' do diff --git a/spec/models/integrations/ewm_spec.rb b/spec/models/integrations/ewm_spec.rb index dc48a2c982f..4f4ff038b19 100644 --- a/spec/models/integrations/ewm_spec.rb +++ b/spec/models/integrations/ewm_spec.rb @@ -31,27 +31,27 @@ RSpec.describe Integrations::Ewm do describe "ReferencePatternValidation" do it "extracts bug" do - expect(described_class.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123") + expect(subject.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123") end it "extracts task" do - expect(described_class.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123") + expect(subject.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123") end it "extracts work item" do - expect(described_class.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123") + expect(subject.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123") end it "extracts workitem" do - expect(described_class.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123") + expect(subject.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123") end it "extracts defect" do - expect(described_class.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123") + expect(subject.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123") end it "extracts rtcwi" do - expect(described_class.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123") + expect(subject.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123") end end end diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb index fad8768cba0..ccea8748d13 100644 --- a/spec/models/integrations/jira_spec.rb +++ b/spec/models/integrations/jira_spec.rb @@ -11,6 +11,8 @@ RSpec.describe Integrations::Jira do let(:url) { 'http://jira.example.com' } let(:api_url) { 'http://api-jira.example.com' } let(:username) { 'jira-username' } + let(:jira_issue_prefix) { '' } + let(:jira_issue_regex) { '' } let(:password) { 'jira-password' } let(:project_key) { nil } let(:transition_id) { 'test27' } @@ -48,6 +50,8 @@ RSpec.describe Integrations::Jira do it { is_expected.to validate_presence_of(:url) } it { is_expected.to validate_presence_of(:username) } it { is_expected.to validate_presence_of(:password) } + it { is_expected.to validate_length_of(:jira_issue_regex).is_at_most(255) } + it { is_expected.to validate_length_of(:jira_issue_prefix).is_at_most(255) } it_behaves_like 'issue tracker integration URL attribute', :url it_behaves_like 'issue tracker integration URL attribute', :api_url @@ -62,6 +66,8 @@ RSpec.describe Integrations::Jira do it { is_expected.not_to validate_presence_of(:url) } it { is_expected.not_to validate_presence_of(:username) } it { is_expected.not_to validate_presence_of(:password) } + it { is_expected.not_to validate_length_of(:jira_issue_regex).is_at_most(255) } + it { is_expected.not_to validate_length_of(:jira_issue_prefix).is_at_most(255) } end describe 'jira_issue_transition_id' do @@ -167,7 +173,7 @@ RSpec.describe Integrations::Jira do subject(:fields) { integration.fields } it 'returns custom fields' do - expect(fields.pluck(:name)).to eq(%w[url api_url username password jira_issue_transition_id]) + expect(fields.pluck(:name)).to eq(%w[url api_url username password jira_issue_regex jira_issue_prefix jira_issue_transition_id]) end end @@ -202,7 +208,7 @@ RSpec.describe Integrations::Jira do end end - describe '.reference_pattern' do + describe '#reference_pattern' do using RSpec::Parameterized::TableSyntax where(:key, :result) do @@ -216,11 +222,77 @@ RSpec.describe Integrations::Jira do '3EXT_EXT-1234' | '' 'CVE-2022-123' | '' 'CVE-123' | 'CVE-123' + 'abc-JIRA-1234' | 'JIRA-1234' end with_them do specify do - expect(described_class.reference_pattern.match(key).to_s).to eq(result) + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + end + end + + context 'with match prefix' do + before do + jira_integration.jira_issue_prefix = 'jira#' + end + + where(:key, :result, :issue_key) do + 'jira##123' | '' | '' + 'jira#1#23#12' | '' | '' + 'jira#JIRA-1234A' | 'jira#JIRA-1234' | 'JIRA-1234' + 'jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234' + 'JIRA-1234A' | '' | '' + 'JIRA-1234-some_tag' | '' | '' + 'myjira#JIRA-1234-some_tag' | '' | '' + 'MYjira#JIRA-1234-some_tag' | '' | '' + 'my-jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234' + end + + with_them do + specify do + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + + expect(jira_integration.reference_pattern.match(key)[:issue]).to eq(issue_key) unless result.empty? + end + end + end + + context 'with trailing space in jira_issue_prefix' do + before do + jira_integration.jira_issue_prefix = 'Jira# ' + end + + it 'leaves the trailing space' do + expect(jira_integration.jira_issue_prefix).to eq('Jira# ') + end + + it 'pulls the issue ID without a prefix' do + expect(jira_integration.reference_pattern.match('Jira# FOO-123')[:issue]).to eq('FOO-123') + end + end + + context 'with custom issue pattern' do + before do + jira_integration.jira_issue_regex = '[A-Z][0-9]-[0-9]+' + end + + where(:key, :result) do + 'J1-123' | 'J1-123' + 'AAbJ J1-123' | 'J1-123' + '#A1-123' | 'A1-123' + 'J1-1234-some_tag' | 'J1-1234' + 'J1-1234A' | 'J1-1234' + 'J1-1234-some_tag' | 'J1-1234' + 'JI1-123' | '' + 'J1I-123' | '' + 'JI-123' | '' + '#123' | '' + end + + with_them do + specify do + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + end end end end @@ -252,6 +324,8 @@ RSpec.describe Integrations::Jira do url: url, api_url: api_url, username: username, password: password, + jira_issue_regex: jira_issue_regex, + jira_issue_prefix: jira_issue_prefix, jira_issue_transition_id: transition_id } end @@ -267,6 +341,8 @@ RSpec.describe Integrations::Jira do expect(integration.jira_tracker_data.api_url).to eq(api_url) expect(integration.jira_tracker_data.username).to eq(username) expect(integration.jira_tracker_data.password).to eq(password) + expect(integration.jira_tracker_data.jira_issue_regex).to eq(jira_issue_regex) + expect(integration.jira_tracker_data.jira_issue_prefix).to eq(jira_issue_prefix) expect(integration.jira_tracker_data.jira_issue_transition_id).to eq(transition_id) expect(integration.jira_tracker_data.deployment_cloud?).to be_truthy end diff --git a/spec/models/integrations/redmine_spec.rb b/spec/models/integrations/redmine_spec.rb index 59997d2b6f6..8785fc8a1ed 100644 --- a/spec/models/integrations/redmine_spec.rb +++ b/spec/models/integrations/redmine_spec.rb @@ -38,11 +38,11 @@ RSpec.describe Integrations::Redmine do end end - describe '.reference_pattern' do + describe '#reference_pattern' do it_behaves_like 'allows project key on reference pattern' it 'does allow # on the reference' do - expect(described_class.reference_pattern.match('#123')[:issue]).to eq('123') + expect(subject.reference_pattern.match('#123')[:issue]).to eq('123') end end end diff --git a/spec/models/integrations/youtrack_spec.rb b/spec/models/integrations/youtrack_spec.rb index 618ebcbb76a..69dda244413 100644 --- a/spec/models/integrations/youtrack_spec.rb +++ b/spec/models/integrations/youtrack_spec.rb @@ -26,15 +26,15 @@ RSpec.describe Integrations::Youtrack do end end - describe '.reference_pattern' do + describe '#reference_pattern' do it_behaves_like 'allows project key on reference pattern' it 'does allow project prefix on the reference' do - expect(described_class.reference_pattern.match('YT-123')[:issue]).to eq('YT-123') + expect(subject.reference_pattern.match('YT-123')[:issue]).to eq('YT-123') end it 'allows lowercase project key on the reference' do - expect(described_class.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') + expect(subject.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index d1e0ad7e5bb..eaac88d2964 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -12,7 +12,6 @@ RSpec.describe Issue, feature_category: :team_planning do describe "Associations" do it { is_expected.to belong_to(:milestone) } - it { is_expected.to belong_to(:iteration) } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type') } it { is_expected.to belong_to(:moved_to).class_name('Issue') } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index b82b16fdec3..f9822ff36c6 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -30,7 +30,6 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev it { is_expected.to have_many(:merge_request_diffs) } it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") } it { is_expected.to belong_to(:milestone) } - it { is_expected.to belong_to(:iteration) } it { is_expected.to have_many(:resource_milestone_events) } it { is_expected.to have_many(:resource_state_events) } it { is_expected.to have_many(:draft_notes) } diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb index c35b9bab0ec..de5cb81190f 100644 --- a/spec/requests/api/integrations_spec.rb +++ b/spec/requests/api/integrations_spec.rb @@ -62,7 +62,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do datadog: %i[archive_trace_events], discord: %i[branches_to_be_notified notify_only_broken_pipelines], hangouts_chat: %i[notify_only_broken_pipelines], - jira: %i[issues_enabled project_key vulnerabilities_enabled vulnerabilities_issuetype], + jira: %i[issues_enabled project_key jira_issue_regex jira_issue_prefix vulnerabilities_enabled vulnerabilities_issuetype], mattermost: %i[deployment_channel labels_to_be_notified], mock_ci: %i[enable_ssl_verification], prometheus: %i[manual_configuration], diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb index a6a072e2caf..032cc12ab94 100644 --- a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb +++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb @@ -78,30 +78,6 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do end RUBY end - - context 'for migrations before 2021_09_10_00_00_00' do - it 'when limit: attribute is used (which is not supported yet for this version): registers an offense' do - allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE - 5) - - expect_offense(<<~RUBY) - class TestTextLimits < ActiveRecord::Migration[6.0] - def up - create_table :test_text_limit_attribute do |t| - t.integer :test_id, null: false - t.text :name, limit: 100 - ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` - end - - create_table_with_constraints :test_text_limit_attribute do |t| - t.integer :test_id, null: false - t.text :name, limit: 100 - ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table` - end - end - end - RUBY - end - end end context 'when text array columns are defined without a limit' do diff --git a/spec/scripts/create_pipeline_failure_incident_spec.rb b/spec/scripts/create_pipeline_failure_incident_spec.rb index 8549cec1b12..efbd22ccb32 100644 --- a/spec/scripts/create_pipeline_failure_incident_spec.rb +++ b/spec/scripts/create_pipeline_failure_incident_spec.rb @@ -15,7 +15,7 @@ RSpec.describe CreatePipelineFailureIncident, feature_category: :tooling do let(:options) do { - project: 1234, + project: 'gitlab-org/gitlab-test-project', api_token: 'asdf1234' } end @@ -104,7 +104,6 @@ RSpec.describe CreatePipelineFailureIncident, feature_category: :tooling do end context 'when other branch' do - let(:incident_labels) { ['Engineering Productivity', 'master-broken::undetermined', 'master:broken'] } let(:title) { /broken `master`/ } let(:description) { /Follow the \[Broken `master` handbook guide\]/ } @@ -114,6 +113,49 @@ RSpec.describe CreatePipelineFailureIncident, feature_category: :tooling do ) end + context 'when GitLab FOSS' do + let(:incident_labels) { ['master:foss-broken', 'Engineering Productivity', 'master-broken::undetermined'] } + + before do + stub_env( + 'CI_PROJECT_NAME' => 'gitlab-foss' + ) + end + + it_behaves_like 'creating an issue' + end + + context 'when GitLab EE' do + let(:incident_labels) { ['master:broken', 'Engineering Productivity', 'master-broken::undetermined'] } + + before do + stub_env( + 'CI_PROJECT_NAME' => 'gitlab' + ) + end + + it_behaves_like 'creating an issue' + end + end + + context 'when review-apps' do + let(:options) do + { + project: 'gitlab-org/quality/engineering-productivity/review-apps-broken-incidents', + api_token: 'asdf1234' + } + end + + let(:incident_labels) { ["review-apps-broken", "Engineering Productivity", "ep::review-apps"] } + let(:title) { /broken `my-branch`/ } + let(:description) { /Please refer to \[the review-apps triaging process\]/ } + + before do + stub_env( + 'CI_COMMIT_REF_NAME' => 'my-branch' + ) + end + it_behaves_like 'creating an issue' end end diff --git a/spec/scripts/generate_failed_pipeline_slack_message_spec.rb b/spec/scripts/generate_failed_pipeline_slack_message_spec.rb new file mode 100644 index 00000000000..2418116e694 --- /dev/null +++ b/spec/scripts/generate_failed_pipeline_slack_message_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../scripts/generate-failed-pipeline-slack-message' +require_relative '../support/helpers/stub_env' + +RSpec.describe GenerateFailedPipelineSlackMessage, feature_category: :tooling do + include StubENV + + describe '#execute' do + let(:create_issue) { instance_double(CreateIssue) } + let(:issue) { double('Issue', iid: 1) } # rubocop:disable RSpec/VerifiedDoubles + let(:create_issue_discussion) { instance_double(CreateIssueDiscussion, execute: true) } + let(:failed_jobs) { instance_double(PipelineFailedJobs, execute: []) } + + let(:project_path) { 'gitlab-org/gitlab-test-project' } + let(:options) do + { + project: project_path, + incident_json_file: 'incident_json_file_tests.json' + } + end + + subject { described_class.new(options).execute } + + before do + stub_env( + 'CI_COMMIT_REF_NAME' => 'my-branch', + 'CI_COMMIT_SHA' => 'bfcd2b9b5cad0b889494ce830697392c8ca11257', + 'CI_COMMIT_TITLE' => 'Commit title', + 'CI_PIPELINE_CREATED_AT' => '2023-01-24 00:00:00', + 'CI_PIPELINE_ID' => '1234567', + 'CI_PIPELINE_SOURCE' => 'push', + 'CI_PIPELINE_URL' => 'https://gitlab.com/gitlab-org/gitlab/-/pipelines/1234567', + 'CI_PROJECT_PATH' => 'gitlab.com/gitlab-org/gitlab', + 'CI_PROJECT_URL' => 'https://gitlab.com/gitlab-org/gitlab', + 'CI_SERVER_URL' => 'https://gitlab.com', + 'GITLAB_USER_ID' => '1111', + 'GITLAB_USER_LOGIN' => 'foo', + 'GITLAB_USER_NAME' => 'Foo User', + 'PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE' => 'asdf1234', + 'SLACK_CHANNEL' => '#a-slack-channel' + ) + + allow(PipelineFailedJobs).to receive(:new) + .with(API::DEFAULT_OPTIONS.merge(exclude_allowed_to_fail_jobs: true)) + .and_return(failed_jobs) + end + + it 'returns the correct keys' do + expect(subject.keys).to match_array([:channel, :username, :icon_emoji, :text, :blocks]) + end + + it 'returns the correct channel' do + expect(subject[:channel]).to eq('#a-slack-channel') + end + + it 'returns the correct username' do + expect(subject[:username]).to eq('Failed pipeline reporter') + end + + it 'returns the correct icon_emoji' do + expect(subject[:icon_emoji]).to eq(':boom:') + end + + it 'returns the correct text' do + expect(subject[:text]).to eq( + '*<https://gitlab.com/gitlab-org/gitlab|gitlab.com/gitlab-org/gitlab> pipeline ' \ + '<https://gitlab.com/gitlab-org/gitlab/-/pipelines/1234567|#1234567> failed*' + ) + end + + it 'returns the correct incident button link' do + block_with_incident_link = subject[:blocks].detect { |block| block.key?(:accessory) } + + expect(block_with_incident_link[:accessory][:url]).to eq( + "https://gitlab.com/#{project_path}/-/issues/new?issuable_template=incident&issue%5Bissue_type%5D=incident" + ) + end + end +end diff --git a/spec/scripts/review_apps/automated_cleanup_spec.rb b/spec/scripts/review_apps/automated_cleanup_spec.rb index 4b6016760dc..40cf0a8bf75 100644 --- a/spec/scripts/review_apps/automated_cleanup_spec.rb +++ b/spec/scripts/review_apps/automated_cleanup_spec.rb @@ -76,6 +76,54 @@ RSpec.describe ReviewApps::AutomatedCleanup, feature_category: :tooling do end end + describe '.parse_args' do + subject { described_class.parse_args(argv) } + + context 'when no arguments are provided' do + let(:argv) { %w[] } + + it 'returns the default options' do + expect(subject).to eq(dry_run: false) + end + end + + describe '--dry-run' do + context 'when no DRY_RUN variable is provided' do + let(:argv) { ['--dry-run='] } + + # This is the default behavior of OptionParser. + # We should always pass an environment variable with a value, or not pass the flag at all. + it 'raises an error' do + expect { subject }.to raise_error(OptionParser::InvalidArgument, 'invalid argument: --dry-run=') + end + end + + context 'when the DRY_RUN variable is not set to true' do + let(:argv) { %w[--dry-run=false] } + + it 'returns the default options' do + expect(subject).to eq(dry_run: false) + end + end + + context 'when the DRY_RUN variable is set to true' do + let(:argv) { %w[--dry-run=true] } + + it 'returns the correct dry_run value' do + expect(subject).to eq(dry_run: true) + end + end + + context 'when the short version of the flag is used' do + let(:argv) { %w[-d true] } + + it 'returns the correct dry_run value' do + expect(subject).to eq(dry_run: true) + end + end + end + end + describe '#perform_stale_pvc_cleanup!' do subject { instance.perform_stale_pvc_cleanup!(days: days) } diff --git a/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb b/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb index 6d519e561ee..d438918eb60 100644 --- a/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb +++ b/spec/support/shared_examples/models/issue_tracker_service_shared_examples.rb @@ -10,19 +10,19 @@ end RSpec.shared_examples 'allows project key on reference pattern' do |url_attr| it 'allows underscores in the project name' do - expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234' + expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234' end it 'allows numbers in the project name' do - expect(described_class.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234' + expect(subject.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234' end it 'requires the project name to begin with A-Z' do - expect(described_class.reference_pattern.match('3EXT_EXT-1234')).to eq nil - expect(described_class.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234' + expect(subject.reference_pattern.match('3EXT_EXT-1234')).to eq nil + expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234' end it 'does not allow issue number to finish with a letter' do - expect(described_class.reference_pattern.match('EXT-123A')).to eq(nil) + expect(subject.reference_pattern.match('EXT-123A')).to eq(nil) end end |