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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
commite8d2c2579383897a1dd7f9debd359abe8ae8373d (patch)
treec42be41678c2586d49a75cabce89322082698334 /app
parentfc845b37ec3a90aaa719975f607740c22ba6a113 (diff)
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js3
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue31
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue41
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue69
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue21
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue23
-rw-r--r--app/assets/javascripts/admin/users/components/actions/index.js4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue51
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue53
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue20
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue18
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue (renamed from app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue)4
-rw-r--r--app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue (renamed from app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue)0
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue117
-rw-r--r--app/assets/javascripts/admin/users/constants.js10
-rw-r--r--app/assets/javascripts/admin/users/index.js65
-rw-r--r--app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue (renamed from app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue)23
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js)18
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue121
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue80
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue241
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js12
-rw-r--r--app/assets/javascripts/analytics/shared/graphql/projects.query.graphql22
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue32
-rw-r--r--app/assets/javascripts/api.js10
-rw-r--r--app/assets/javascripts/api/analytics_api.js19
-rw-r--r--app/assets/javascripts/api/constants.js1
-rw-r--r--app/assets/javascripts/api/groups_api.js2
-rw-r--r--app/assets/javascripts/api/projects_api.js2
-rw-r--r--app/assets/javascripts/api/user_api.js10
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/badges/components/badge.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue8
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js10
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js8
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js6
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue6
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue2
-rw-r--r--app/assets/javascripts/blob/csv/csv_viewer.vue55
-rw-r--r--app/assets/javascripts/blob/csv/index.js17
-rw-r--r--app/assets/javascripts/blob/csv_viewer.js3
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js11
-rw-r--r--app/assets/javascripts/blob/openapi/index.js6
-rw-r--r--app/assets/javascripts/blob/utils.js4
-rw-r--r--app/assets/javascripts/blob/viewer/index.js40
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js8
-rw-r--r--app/assets/javascripts/boards/boards_util.js3
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue131
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue24
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue182
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue23
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue12
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue31
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue35
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue24
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue83
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js2
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue2
-rw-r--r--app/assets/javascripts/boards/components/boards_selector_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue102
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js6
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue2
-rw-r--r--app/assets/javascripts/boards/components/project_select_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue2
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql1
-rw-r--r--app/assets/javascripts/boards/index.js26
-rw-r--r--app/assets/javascripts/boards/issue_board_filters.js47
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js2
-rw-r--r--app/assets/javascripts/boards/models/list.js14
-rw-r--r--app/assets/javascripts/boards/mount_filtered_search_issue_boards.js31
-rw-r--r--app/assets/javascripts/boards/stores/actions.js29
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js11
-rw-r--r--app/assets/javascripts/boards/stores/getters.js2
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js18
-rw-r--r--app/assets/javascripts/branches/components/delete_branch_button.vue8
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js2
-rw-r--r--app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js56
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue6
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js221
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue478
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue662
-rw-r--r--app/assets/javascripts/clusters/components/crossplane_provider_stack.vue93
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue232
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_button.vue36
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue101
-rw-r--r--app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue66
-rw-r--r--app/assets/javascripts/clusters/constants.js57
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js250
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js26
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js207
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js6
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/utils.js3
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js14
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue20
-rw-r--r--app/assets/javascripts/commit_merge_requests.js9
-rw-r--r--app/assets/javascripts/commits.js2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue31
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue110
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue91
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue28
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue31
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js130
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js33
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_row.js51
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js61
-rw-r--r--app/assets/javascripts/content_editor/services/upload_file.js44
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js14
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue36
-rw-r--r--app/assets/javascripts/contributors/stores/actions.js8
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/index.js6
-rw-r--r--app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/filter_bar.vue142
-rw-r--r--app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue32
-rw-r--r--app/assets/javascripts/cycle_analytics/components/path_navigation.vue10
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue93
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js62
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js29
-rw-r--r--app/assets/javascripts/cycle_analytics/store/index.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js38
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js20
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue18
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue6
-rw-r--r--app/assets/javascripts/design_management/components/design_todo_button.vue24
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue4
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js8
-rw-r--r--app/assets/javascripts/diff.js8
-rw-r--r--app/assets/javascripts/diffs/components/app.vue92
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue7
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue21
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue38
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue89
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue35
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue556
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js41
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue74
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue204
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue117
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue310
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue142
-rw-r--r--app/assets/javascripts/diffs/components/pre_renderer.vue84
-rw-r--r--app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js51
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/index.js3
-rw-r--r--app/assets/javascripts/diffs/store/actions.js60
-rw-r--r--app/assets/javascripts/diffs/store/getters.js6
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js3
-rw-r--r--app/assets/javascripts/diffs/store/utils.js10
-rw-r--r--app/assets/javascripts/editor/constants.js7
-rw-r--r--app/assets/javascripts/editor/extensions/editor_file_template_ext.js8
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js (renamed from app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js)4
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js (renamed from app/assets/javascripts/editor/extensions/editor_lite_extension_base.js)10
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js8
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js (renamed from app/assets/javascripts/editor/extensions/editor_markdown_ext.js)4
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_webide_ext.js (renamed from app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js)4
-rw-r--r--app/assets/javascripts/editor/source_editor.js (renamed from app/assets/javascripts/editor/editor_lite.js)42
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue1
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue16
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue8
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue6
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js19
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue2
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js1
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue37
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue7
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue68
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue428
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue40
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue2
-rw-r--r--app/assets/javascripts/feature_flags/edit.js7
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/actions.js10
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/mutations.js3
-rw-r--r--app/assets/javascripts/feature_flags/store/helpers.js149
-rw-r--r--app/assets/javascripts/feature_flags/store/index/mutations.js7
-rw-r--r--app/assets/javascripts/feature_flags/store/new/actions.js10
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js8
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js13
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js5
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js14
-rw-r--r--app/assets/javascripts/flash.js27
-rw-r--r--app/assets/javascripts/fly_out_nav.js11
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue32
-rw-r--r--app/assets/javascripts/frequent_items/store/actions.js3
-rw-r--r--app/assets/javascripts/gpg_badges.js5
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js1
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js13
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js5
-rw-r--r--app/assets/javascripts/group.js12
-rw-r--r--app/assets/javascripts/group_label_subscription.js14
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue2
-rw-r--r--app/assets/javascripts/groups/components/app.vue10
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue34
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue2
-rw-r--r--app/assets/javascripts/groups/groups_filterable_list.js9
-rw-r--r--app/assets/javascripts/ide/components/error_message.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_project_header.vue12
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue3
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue6
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue27
-rw-r--r--app/assets/javascripts/ide/components/shared/tokened_input.vue2
-rw-r--r--app/assets/javascripts/ide/components/terminal/terminal.vue2
-rw-r--r--app/assets/javascripts/ide/ide_router.js15
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js3
-rw-r--r--app/assets/javascripts/ide/services/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions.js32
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js21
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js8
-rw-r--r--app/assets/javascripts/ide/stores/utils.js4
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue40
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue26
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue14
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue62
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js2
-rw-r--r--app/assets/javascripts/incidents_settings/incidents_settings_service.js1
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue42
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue79
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue24
-rw-r--r--app/assets/javascripts/invite_members/constants.js6
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js65
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue4
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue58
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/constants.js17
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js17
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js (renamed from app/assets/javascripts/issuable_bulk_update_actions.js)10
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js (renamed from app/assets/javascripts/issuable_bulk_update_sidebar.js)13
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js (renamed from app/assets/javascripts/issuable_init_bulk_update_sidebar.js)0
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js (renamed from app/assets/javascripts/subscription_select.js)2
-rw-r--r--app/assets/javascripts/issuable_context.js30
-rw-r--r--app/assets/javascripts/issuable_create/components/issuable_form.vue21
-rw-r--r--app/assets/javascripts/issuable_index.js2
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue2
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue6
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue23
-rw-r--r--app/assets/javascripts/issuable_sidebar/constants.js1
-rw-r--r--app/assets/javascripts/issue.js12
-rw-r--r--app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql1
-rw-r--r--app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql4
-rw-r--r--app/assets/javascripts/issue_show/services/index.js2
-rw-r--r--app/assets/javascripts/issue_status_select.js27
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue22
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue2
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue208
-rw-r--r--app/assets/javascripts/issues_list/constants.js235
-rw-r--r--app/assets/javascripts/issues_list/index.js12
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues.query.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql26
-rw-r--r--app/assets/javascripts/issues_list/queries/issue.fragment.graphql2
-rw-r--r--app/assets/javascripts/issues_list/queries/search_iterations.query.graphql10
-rw-r--r--app/assets/javascripts/issues_list/queries/search_labels.query.graphql12
-rw-r--r--app/assets/javascripts/issues_list/queries/search_milestones.query.graphql10
-rw-r--r--app/assets/javascripts/issues_list/queries/search_users.query.graphql14
-rw-r--r--app/assets/javascripts/issues_list/utils.js54
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue95
-rw-r--r--app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue134
-rw-r--r--app/assets/javascripts/jira_connect/branches/constants.js2
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql17
-rw-r--r--app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql34
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list.vue1
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue4
-rw-r--r--app/assets/javascripts/jobs/components/empty_state.vue11
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue6
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue33
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue6
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue39
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue2
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/jobs/store/actions.js24
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js28
-rw-r--r--app/assets/javascripts/jobs/store/state.js3
-rw-r--r--app/assets/javascripts/jobs/store/utils.js75
-rw-r--r--app/assets/javascripts/jobs/utils.js6
-rw-r--r--app/assets/javascripts/label_manager.js12
-rw-r--r--app/assets/javascripts/labels_select.js16
-rw-r--r--app/assets/javascripts/lib/dompurify.js11
-rw-r--r--app/assets/javascripts/lib/graphql.js52
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js3
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js139
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime/timeago_utility.js47
-rw-r--r--app/assets/javascripts/lib/utils/finite_state_machine.js101
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js58
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js76
-rw-r--r--app/assets/javascripts/line_highlighter.js4
-rw-r--r--app/assets/javascripts/locale/index.js47
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue8
-rw-r--r--app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue2
-rw-r--r--app/assets/javascripts/main.js9
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue1
-rw-r--r--app/assets/javascripts/members/components/app.vue7
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue20
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue45
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue19
-rw-r--r--app/assets/javascripts/members/constants.js9
-rw-r--r--app/assets/javascripts/members/utils.js4
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue12
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue2
-rw-r--r--app/assets/javascripts/merge_request.js8
-rw-r--r--app/assets/javascripts/merge_request_tabs.js28
-rw-r--r--app/assets/javascripts/milestone.js8
-rw-r--r--app/assets/javascripts/milestone_select.js17
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue4
-rw-r--r--app/assets/javascripts/milestones/milestone_utils.js32
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js8
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js6
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue20
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js12
-rw-r--r--app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue4
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_item.vue2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_sections.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue60
-rw-r--r--app/assets/javascripts/notes.js5
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue25
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue14
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue22
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue13
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js7
-rw-r--r--app/assets/javascripts/notes/stores/actions.js84
-rw-r--r--app/assets/javascripts/notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/notes/utils.js7
-rw-r--r--app/assets/javascripts/notifications/components/custom_notifications_modal.vue6
-rw-r--r--app/assets/javascripts/notifications/components/notifications_dropdown.vue2
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js1
-rw-r--r--app/assets/javascripts/packages/details/components/app.vue4
-rw-r--r--app/assets/javascripts/packages/list/constants.js8
-rw-r--r--app/assets/javascripts/packages/shared/constants.js2
-rw-r--r--app/assets/javascripts/packages/shared/utils.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue301
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.js26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/utils.js7
-rw-r--r--app/assets/javascripts/pager.js3
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js41
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js6
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js11
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/clusters/destroy/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/clusters/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/clusters/new/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/clusters/show/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/identities/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/integrations/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/keys/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/labels/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue2
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/spam_logs/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js67
-rw-r--r--app/assets/javascripts/pages/admin/users/keys/index.js5
-rw-r--r--app/assets/javascripts/pages/dashboard/groups/index/index.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/show/index.js8
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js12
-rw-r--r--app/assets/javascripts/pages/explore/projects/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/destroy/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/clusters/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/show/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/milestones/show/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js21
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue14
-rw-r--r--app/assets/javascripts/pages/profiles/notifications/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/browse/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/file/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/clusters/destroy/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/clusters/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue14
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue2
-rw-r--r--app/assets/javascripts/pages/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue43
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js8
-rw-r--r--app/assets/javascripts/pages/projects/new/components/app.vue26
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/show/index.js14
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/project.js8
-rw-r--r--app/assets/javascripts/pages/projects/security/configuration/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue12
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js8
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js8
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue18
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js6
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js8
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js4
-rw-r--r--app/assets/javascripts/performance/constants.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue9
-rw-r--r--app/assets/javascripts/persistent_user_callout.js14
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue38
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue10
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue8
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue3
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js10
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql12
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql4
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql12
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js24
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue110
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js6
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js17
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue51
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue (renamed from app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue)17
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue64
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue23
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue46
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js14
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js6
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js1
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js4
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue3
-rw-r--r--app/assets/javascripts/profile/profile.js13
-rw-r--r--app/assets/javascripts/project_find_file.js8
-rw-r--r--app/assets/javascripts/project_label_subscription.js8
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue5
-rw-r--r--app/assets/javascripts/projects/components/project_delete_button.vue4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue10
-rw-r--r--app/assets/javascripts/projects/project_new.js21
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue59
-rw-r--r--app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js8
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue7
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue2
-rw-r--r--app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue65
-rw-r--r--app/assets/javascripts/projects/terraform_notification/index.js18
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue6
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js8
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js6
-rw-r--r--app/assets/javascripts/ref/constants.js3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue71
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue37
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue9
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue3
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue2
-rw-r--r--app/assets/javascripts/releases/components/app_index_apollo_client.vue6
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql14
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql9
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js1
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js9
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue21
-rw-r--r--app/assets/javascripts/reports/constants.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_button_group.vue (renamed from app/assets/javascripts/repository/components/blob_replace.vue)47
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue67
-rw-r--r--app/assets/javascripts/repository/components/blob_edit.vue (renamed from app/assets/javascripts/repository/components/blob_header_edit.vue)0
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue51
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue3
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js27
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue25
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue151
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue2
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue25
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue12
-rw-r--r--app/assets/javascripts/repository/constants.js8
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql5
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql1
-rw-r--r--app/assets/javascripts/right_sidebar.js40
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue21
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_type_cell.vue4
-rw-r--r--app/assets/javascripts/runner/components/helpers/masked_value.vue60
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue141
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue36
-rw-r--r--app/assets/javascripts/runner/components/runner_manual_setup_help.vue6
-rw-r--r--app/assets/javascripts/runner/components/runner_registration_token_reset.vue10
-rw-r--r--app/assets/javascripts/runner/components/runner_tag.vue27
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue13
-rw-r--r--app/assets/javascripts/runner/components/runner_type_help.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue67
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/tag_token.vue91
-rw-r--r--app/assets/javascripts/runner/constants.js9
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql6
-rw-r--r--app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql (renamed from app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql)0
-rw-r--r--app/assets/javascripts/runner/graphql/runner_details.fragment.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql12
-rw-r--r--app/assets/javascripts/runner/graphql/runner_node.fragment.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/runner_update.mutation.graphql2
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_details_app.vue34
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_update_form_utils.js38
-rw-r--r--app/assets/javascripts/runner/runner_list/index.js3
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_list_app.vue35
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_search_utils.js17
-rw-r--r--app/assets/javascripts/runner/sentry_utils.js20
-rw-r--r--app/assets/javascripts/search/store/actions.js45
-rw-r--r--app/assets/javascripts/search/store/constants.js7
-rw-r--r--app/assets/javascripts/search/store/getters.js9
-rw-r--r--app/assets/javascripts/search/store/index.js2
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/search/store/mutations.js3
-rw-r--r--app/assets/javascripts/search/store/state.js6
-rw-r--r--app/assets/javascripts/search/store/utils.js80
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue12
-rw-r--r--app/assets/javascripts/search/topbar/components/project_filter.vue12
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue41
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue8
-rw-r--r--app/assets/javascripts/search_autocomplete.js10
-rw-r--r--app/assets/javascripts/search_autocomplete_utils.js19
-rw-r--r--app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue41
-rw-r--r--app/assets/javascripts/security_configuration/components/configuration_table.vue2
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js58
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue3
-rw-r--r--app/assets/javascripts/security_configuration/components/redesigned_app.vue82
-rw-r--r--app/assets/javascripts/security_configuration/components/section_layout.vue6
-rw-r--r--app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql6
-rw-r--r--app/assets/javascripts/security_configuration/index.js81
-rw-r--r--app/assets/javascripts/security_configuration/utils.js4
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue2
-rw-r--r--app/assets/javascripts/sentry/index.js1
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js4
-rw-r--r--app/assets/javascripts/service_ping_consent.js (renamed from app/assets/javascripts/usage_ping_consent.js)15
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue44
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue195
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue2
-rw-r--r--app/assets/javascripts/sidebar/constants.js50
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js9
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js78
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_reference.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_todo.query.graphql14
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_todo.query.graphql14
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql14
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql14
-rw-r--r--app/assets/javascripts/sidebar/queries/milestone.fragment.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/project_milestones.query.graphql8
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql3
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql17
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js3
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js8
-rw-r--r--app/assets/javascripts/smart_interval.js29
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/embed_dropdown.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue2
-rw-r--r--app/assets/javascripts/sortable/sortable_config.js1
-rw-r--r--app/assets/javascripts/star.js8
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_drawer.vue2
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js5
-rw-r--r--app/assets/javascripts/static_site_editor/image_repository.js7
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue12
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js8
-rw-r--r--app/assets/javascripts/task_list.js6
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue4
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue2
-rw-r--r--app/assets/javascripts/toggle_buttons.js6
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue206
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue81
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql5
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql5
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql7
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql12
-rw-r--r--app/assets/javascripts/token_access/index.js31
-rw-r--r--app/assets/javascripts/tracking/index.js21
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists.vue8
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue42
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js5
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue6
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue (renamed from app/assets/javascripts/vue_shared/components/project_avatar/default.vue)1
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue (renamed from app/assets/javascripts/vue_shared/components/project_avatar/image.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_alert.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue77
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.stories.js30
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue (renamed from app/assets/javascripts/vue_shared/components/editor_lite.vue)8
-rw-r--r--app/assets/javascripts/vue_shared/components/todo_button.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue10
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue14
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue12
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue35
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue13
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/vuex_shared/bindings.js6
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue2
-rw-r--r--app/assets/stylesheets/application_dark.scss4
-rw-r--r--app/assets/stylesheets/components/avatar.scss13
-rw-r--r--app/assets/stylesheets/components/batch_comments/review_bar.scss10
-rw-r--r--app/assets/stylesheets/framework.scss4
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss (renamed from app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss)110
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_header.scss1
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss7
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss386
-rw-r--r--app/assets/stylesheets/framework/diffs.scss8
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss18
-rw-r--r--app/assets/stylesheets/framework/files.scss22
-rw-r--r--app/assets/stylesheets/framework/flash.scss11
-rw-r--r--app/assets/stylesheets/framework/header.scss5
-rw-r--r--app/assets/stylesheets/framework/lists.scss6
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss (renamed from app/assets/stylesheets/framework/editor-lite.scss)4
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss3
-rw-r--r--app/assets/stylesheets/framework/variables.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/members.scss152
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/new_namespace.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss6
-rw-r--r--app/assets/stylesheets/pages/clusters.scss14
-rw-r--r--app/assets/stylesheets/pages/commits.scss2
-rw-r--r--app/assets/stylesheets/pages/issuable.scss24
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss10
-rw-r--r--app/assets/stylesheets/pages/notes.scss1
-rw-r--r--app/assets/stylesheets/snippets.scss15
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss831
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss693
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss38
-rw-r--r--app/assets/stylesheets/themes/_dark.scss11
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss25
-rw-r--r--app/assets/stylesheets/themes/theme_indigo.scss2
-rw-r--r--app/assets/stylesheets/utilities.scss47
-rw-r--r--app/controllers/admin/application_settings_controller.rb8
-rw-r--r--app/controllers/admin/background_migrations_controller.rb14
-rw-r--r--app/controllers/admin/ci/variables_controller.rb2
-rw-r--r--app/controllers/admin/cohorts_controller.rb6
-rw-r--r--app/controllers/admin/integrations_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/controllers/admin/usage_trends_controller.rb4
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/boards/issues_controller.rb2
-rw-r--r--app/controllers/chaos_controller.rb2
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb4
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb2
-rw-r--r--app/controllers/concerns/issuable_actions.rb7
-rw-r--r--app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb2
-rw-r--r--app/controllers/concerns/redis_tracking.rb7
-rw-r--r--app/controllers/concerns/spammable_actions.rb35
-rw-r--r--app/controllers/concerns/wiki_actions.rb3
-rw-r--r--app/controllers/dashboard/milestones_controller.rb2
-rw-r--r--app/controllers/dashboard/todos_controller.rb1
-rw-r--r--app/controllers/groups/application_controller.rb8
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/group_members_controller.rb8
-rw-r--r--app/controllers/groups/milestones_controller.rb2
-rw-r--r--app/controllers/groups/runners_controller.rb2
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/help_controller.rb2
-rw-r--r--app/controllers/import/bulk_imports_controller.rb4
-rw-r--r--app/controllers/invites_controller.rb19
-rw-r--r--app/controllers/jira_connect/events_controller.rb2
-rw-r--r--app/controllers/metrics_controller.rb2
-rw-r--r--app/controllers/profiles/gpg_keys_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb6
-rw-r--r--app/controllers/projects/artifacts_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb16
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb2
-rw-r--r--app/controllers/projects/commit_controller.rb3
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb8
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb2
-rw-r--r--app/controllers/projects/feature_flags_controller.rb4
-rw-r--r--app/controllers/projects/forks_controller.rb2
-rw-r--r--app/controllers/projects/graphs_controller.rb4
-rw-r--r--app/controllers/projects/import/jira_controller.rb6
-rw-r--r--app/controllers/projects/issues_controller.rb15
-rw-r--r--app/controllers/projects/jobs_controller.rb4
-rw-r--r--app/controllers/projects/mattermosts_controller.rb14
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb14
-rw-r--r--app/controllers/projects/merge_requests_controller.rb9
-rw-r--r--app/controllers/projects/milestones_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb19
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb6
-rw-r--r--app/controllers/projects/releases_controller.rb6
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb16
-rw-r--r--app/controllers/projects/service_hook_logs_controller.rb15
-rw-r--r--app/controllers/projects/service_ping_controller.rb (renamed from app/controllers/projects/usage_ping_controller.rb)4
-rw-r--r--app/controllers/projects/services_controller.rb18
-rw-r--r--app/controllers/projects/settings/access_tokens_controller.rb10
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb1
-rw-r--r--app/controllers/projects/settings/integrations_controller.rb2
-rw-r--r--app/controllers/projects/settings/packages_and_registries_controller.rb7
-rw-r--r--app/controllers/projects/tree_controller.rb4
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb10
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb2
-rw-r--r--app/controllers/registrations/invites_controller.rb9
-rw-r--r--app/controllers/registrations/welcome_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb1
-rw-r--r--app/controllers/repositories/git_http_controller.rb6
-rw-r--r--app/controllers/search_controller.rb11
-rw-r--r--app/controllers/users/unsubscribes_controller.rb33
-rw-r--r--app/experiments/application_experiment.rb53
-rw-r--r--app/experiments/new_project_readme_content_experiment.rb30
-rw-r--r--app/experiments/templates/new_project_readme_content/readme_advanced.md.tt90
-rw-r--r--app/experiments/templates/new_project_readme_content/readme_basic.md.tt3
-rw-r--r--app/finders/bulk_imports/entities_finder.rb35
-rw-r--r--app/finders/bulk_imports/imports_finder.rb24
-rw-r--r--app/finders/ci/pipelines_finder.rb11
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb3
-rw-r--r--app/finders/ci/runners_finder.rb7
-rw-r--r--app/finders/concerns/merged_at_filter.rb20
-rw-r--r--app/finders/container_repositories_finder.rb2
-rw-r--r--app/finders/environments/environments_finder.rb9
-rw-r--r--app/finders/members_finder.rb5
-rw-r--r--app/finders/merge_requests_finder.rb7
-rw-r--r--app/finders/milestones_finder.rb15
-rw-r--r--app/finders/packages/helm/package_files_finder.rb11
-rw-r--r--app/finders/projects_finder.rb2
-rw-r--r--app/finders/security/jobs_finder.rb4
-rw-r--r--app/finders/security/security_jobs_finder.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/create.rb4
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/update.rb2
-rw-r--r--app/graphql/mutations/boards/issues/issue_move_list.rb2
-rw-r--r--app/graphql/mutations/ci/job_token_scope/add_project.rb49
-rw-r--r--app/graphql/mutations/ci/job_token_scope/remove_project.rb49
-rw-r--r--app/graphql/mutations/ci/runner/update.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/package_eventable.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/spam_protection.rb19
-rw-r--r--app/graphql/mutations/issues/create.rb3
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb8
-rw-r--r--app/graphql/mutations/issues/update.rb3
-rw-r--r--app/graphql/mutations/packages/destroy.rb37
-rw-r--r--app/graphql/mutations/release_asset_links/create.rb4
-rw-r--r--app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb44
-rw-r--r--app/graphql/mutations/security/ci_configuration/configure_sast.rb33
-rw-r--r--app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb33
-rw-r--r--app/graphql/mutations/snippets/create.rb10
-rw-r--r--app/graphql/mutations/snippets/update.rb10
-rw-r--r--app/graphql/queries/container_registry/get_container_repositories.query.graphql2
-rw-r--r--app/graphql/queries/epic/epic_children.query.graphql1
-rw-r--r--app/graphql/queries/pipelines/get_pipeline_details.query.graphql3
-rw-r--r--app/graphql/queries/releases/all_releases.query.graphql105
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb2
-rw-r--r--app/graphql/resolvers/alert_management/integrations_resolver.rb4
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb8
-rw-r--r--app/graphql/resolvers/ci/job_token_scope_resolver.rb21
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb16
-rw-r--r--app/graphql/resolvers/ci/template_resolver.rb2
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb2
-rw-r--r--app/graphql/resolvers/issues_resolver.rb1
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb20
-rw-r--r--app/graphql/resolvers/projects/jira_projects_resolver.rb6
-rw-r--r--app/graphql/types/alert_management/alert_sort_enum.rb4
-rw-r--r--app/graphql/types/alert_management/alert_type.rb2
-rw-r--r--app/graphql/types/alert_management/integration_type.rb2
-rw-r--r--app/graphql/types/alert_management/prometheus_integration_type.rb10
-rw-r--r--app/graphql/types/base_field.rb12
-rw-r--r--app/graphql/types/boards/board_issue_input_base_type.rb2
-rw-r--r--app/graphql/types/ci/build_need_type.rb2
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb7
-rw-r--r--app/graphql/types/ci/group_type.rb8
-rw-r--r--app/graphql/types/ci/job_token_scope_type.rb16
-rw-r--r--app/graphql/types/ci/job_type.rb2
-rw-r--r--app/graphql/types/ci/pipeline_type.rb3
-rw-r--r--app/graphql/types/ci/runner_type.rb36
-rw-r--r--app/graphql/types/ci/stage_type.rb20
-rw-r--r--app/graphql/types/ci/status_action_type.rb7
-rw-r--r--app/graphql/types/deployment_tier_enum.rb14
-rw-r--r--app/graphql/types/design_management/design_type.rb2
-rw-r--r--app/graphql/types/issuable_searchable_field_enum.rb12
-rw-r--r--app/graphql/types/issue_sort_enum.rb2
-rw-r--r--app/graphql/types/issue_type.rb5
-rw-r--r--app/graphql/types/issues/negated_issue_filter_input_type.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb2
-rw-r--r--app/graphql/types/milestone_sort_enum.rb13
-rw-r--r--app/graphql/types/milestone_type.rb3
-rw-r--r--app/graphql/types/mutation_type.rb3
-rw-r--r--app/graphql/types/noteable_type.rb23
-rw-r--r--app/graphql/types/notes/discussion_type.rb10
-rw-r--r--app/graphql/types/notes/noteable_interface.rb (renamed from app/graphql/types/notes/noteable_type.rb)4
-rw-r--r--app/graphql/types/project_type.rb16
-rw-r--r--app/graphql/types/projects/service_type_enum.rb2
-rw-r--r--app/graphql/types/query_complexity_type.rb30
-rw-r--r--app/graphql/types/query_type.rb8
-rw-r--r--app/graphql/types/release_asset_link_type.rb2
-rw-r--r--app/graphql/types/snippet_type.rb2
-rw-r--r--app/graphql/types/snippets/blob_type.rb4
-rw-r--r--app/graphql/types/user_callout_type.rb2
-rw-r--r--app/helpers/admin/user_actions_helper.rb17
-rw-r--r--app/helpers/analytics/unique_visits_helper.rb30
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/application_settings_helper.rb7
-rw-r--r--app/helpers/auto_devops_helper.rb2
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/ci/jobs_helper.rb1
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb16
-rw-r--r--app/helpers/ci/pipelines_helper.rb68
-rw-r--r--app/helpers/ci/variables_helper.rb2
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/commits_helper.rb8
-rw-r--r--app/helpers/custom_metrics_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb10
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/events_helper.rb3
-rw-r--r--app/helpers/gitlab_routing_helper.rb381
-rw-r--r--app/helpers/groups_helper.rb18
-rw-r--r--app/helpers/integrations_helper.rb (renamed from app/helpers/services_helper.rb)117
-rw-r--r--app/helpers/issuables_helper.rb9
-rw-r--r--app/helpers/issues_helper.rb3
-rw-r--r--app/helpers/namespaces_helper.rb4
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb15
-rw-r--r--app/helpers/nav/top_nav_helper.rb4
-rw-r--r--app/helpers/nav_helper.rb6
-rw-r--r--app/helpers/operations_helper.rb13
-rw-r--r--app/helpers/packages_helper.rb27
-rw-r--r--app/helpers/personal_access_tokens_helper.rb7
-rw-r--r--app/helpers/projects/alert_management_helper.rb4
-rw-r--r--app/helpers/projects_helper.rb22
-rw-r--r--app/helpers/registrations_helper.rb7
-rw-r--r--app/helpers/releases_helper.rb23
-rw-r--r--app/helpers/routing/artifacts_helper.rb49
-rw-r--r--app/helpers/routing/graphql_helper.rb13
-rw-r--r--app/helpers/routing/groups/members_helper.rb31
-rw-r--r--app/helpers/routing/members_helper.rb14
-rw-r--r--app/helpers/routing/pipeline_schedules_helper.rb29
-rw-r--r--app/helpers/routing/projects/members_helper.rb31
-rw-r--r--app/helpers/routing/projects_helper.rb85
-rw-r--r--app/helpers/routing/snippets_helper.rb144
-rw-r--r--app/helpers/routing/wiki_helper.rb13
-rw-r--r--app/helpers/search_helper.rb21
-rw-r--r--app/helpers/sessions_helper.rb9
-rw-r--r--app/helpers/sidebars_helper.rb74
-rw-r--r--app/helpers/sorting_helper.rb21
-rw-r--r--app/helpers/sorting_titles_values_helper.rb56
-rw-r--r--app/helpers/tracking_helper.rb2
-rw-r--r--app/helpers/user_callouts_helper.rb2
-rw-r--r--app/helpers/users_helper.rb147
-rw-r--r--app/helpers/whats_new_helper.rb6
-rw-r--r--app/mailers/emails/admin_notification.rb19
-rw-r--r--app/mailers/emails/profile.rb8
-rw-r--r--app/mailers/emails/service_desk.rb8
-rw-r--r--app/mailers/notify.rb1
-rw-r--r--app/models/ability.rb8
-rw-r--r--app/models/alert_management/alert.rb12
-rw-r--r--app/models/alert_management/http_integration.rb4
-rw-r--r--app/models/application_setting.rb12
-rw-r--r--app/models/application_setting/term.rb6
-rw-r--r--app/models/application_setting_implementation.rb19
-rw-r--r--app/models/audit_event.rb7
-rw-r--r--app/models/award_emoji.rb8
-rw-r--r--app/models/blob.rb1
-rw-r--r--app/models/blob_viewer/csv.rb13
-rw-r--r--app/models/blob_viewer/go_mod.rb6
-rw-r--r--app/models/blob_viewer/markup.rb4
-rw-r--r--app/models/bulk_import.rb6
-rw-r--r--app/models/bulk_imports/entity.rb6
-rw-r--r--app/models/bulk_imports/export_status.rb2
-rw-r--r--app/models/bulk_imports/file_transfer/base_config.rb6
-rw-r--r--app/models/bulk_imports/file_transfer/group_config.rb4
-rw-r--r--app/models/bulk_imports/file_transfer/project_config.rb4
-rw-r--r--app/models/ci/base_model.rb17
-rw-r--r--app/models/ci/build.rb42
-rw-r--r--app/models/ci/build_dependencies.rb14
-rw-r--r--app/models/ci/build_metadata.rb4
-rw-r--r--app/models/ci/build_trace_chunk.rb10
-rw-r--r--app/models/ci/build_trace_chunks/fog.rb35
-rw-r--r--app/models/ci/group.rb5
-rw-r--r--app/models/ci/instance_variable.rb2
-rw-r--r--app/models/ci/job_artifact.rb8
-rw-r--r--app/models/ci/job_token/project_scope_link.rb4
-rw-r--r--app/models/ci/pending_build.rb42
-rw-r--r--app/models/ci/pipeline.rb71
-rw-r--r--app/models/ci/runner.rb19
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/integrations/prometheus.rb14
-rw-r--r--app/models/commit_status.rb1
-rw-r--r--app/models/compare.rb4
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb4
-rw-r--r--app/models/concerns/any_field_validation.rb25
-rw-r--r--app/models/concerns/approvable_base.rb13
-rw-r--r--app/models/concerns/atomic_internal_id.rb23
-rw-r--r--app/models/concerns/avatarable.rb7
-rw-r--r--app/models/concerns/cache_markdown_field.rb6
-rw-r--r--app/models/concerns/cascading_namespace_setting_attribute.rb13
-rw-r--r--app/models/concerns/ci/maskable.rb2
-rw-r--r--app/models/concerns/ci/metadatable.rb2
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb1
-rw-r--r--app/models/concerns/has_integrations.rb2
-rw-r--r--app/models/concerns/integrations/has_web_hook.rb36
-rw-r--r--app/models/concerns/issuable.rb30
-rw-r--r--app/models/concerns/milestoneish.rb4
-rw-r--r--app/models/concerns/partitioned_table.rb6
-rw-r--r--app/models/concerns/sortable.rb2
-rw-r--r--app/models/concerns/taggable_queries.rb21
-rw-r--r--app/models/container_repository.rb14
-rw-r--r--app/models/deployment.rb2
-rw-r--r--app/models/diff_discussion.rb7
-rw-r--r--app/models/discussion.rb18
-rw-r--r--app/models/error_tracking.rb7
-rw-r--r--app/models/error_tracking/error.rb23
-rw-r--r--app/models/error_tracking/error_event.rb11
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb2
-rw-r--r--app/models/event.rb15
-rw-r--r--app/models/event_collection.rb1
-rw-r--r--app/models/group.rb21
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/integration.rb113
-rw-r--r--app/models/integrations/bamboo.rb6
-rw-r--r--app/models/integrations/base_monitoring.rb23
-rw-r--r--app/models/integrations/base_slash_commands.rb2
-rw-r--r--app/models/integrations/buildkite.rb28
-rw-r--r--app/models/integrations/campfire.rb32
-rw-r--r--app/models/integrations/confluence.rb2
-rw-r--r--app/models/integrations/datadog.rb50
-rw-r--r--app/models/integrations/discord.rb2
-rw-r--r--app/models/integrations/drone_ci.rb36
-rw-r--r--app/models/integrations/ewm.rb2
-rw-r--r--app/models/integrations/jenkins.rb14
-rw-r--r--app/models/integrations/jira.rb15
-rw-r--r--app/models/integrations/mattermost_slash_commands.rb2
-rw-r--r--app/models/integrations/mock_ci.rb2
-rw-r--r--app/models/integrations/mock_monitoring.rb25
-rw-r--r--app/models/integrations/packagist.rb14
-rw-r--r--app/models/integrations/pipelines_email.rb2
-rw-r--r--app/models/integrations/pivotaltracker.rb17
-rw-r--r--app/models/integrations/prometheus.rb203
-rw-r--r--app/models/integrations/teamcity.rb15
-rw-r--r--app/models/internal_id.rb171
-rw-r--r--app/models/issue.rb78
-rw-r--r--app/models/member.rb135
-rw-r--r--app/models/members/group_member.rb6
-rw-r--r--app/models/members/project_member.rb8
-rw-r--r--app/models/merge_request.rb11
-rw-r--r--app/models/merge_request/cleanup_schedule.rb55
-rw-r--r--app/models/merge_request/diff_commit_user.rb95
-rw-r--r--app/models/merge_request_diff.rb2
-rw-r--r--app/models/merge_request_diff_commit.rb70
-rw-r--r--app/models/milestone.rb13
-rw-r--r--app/models/namespace.rb10
-rw-r--r--app/models/namespace_setting.rb2
-rw-r--r--app/models/namespaces/traversal/linear.rb51
-rw-r--r--app/models/namespaces/traversal/recursive.rb20
-rw-r--r--app/models/note.rb7
-rw-r--r--app/models/operations/feature_flag.rb2
-rw-r--r--app/models/packages/debian.rb4
-rw-r--r--app/models/packages/event.rb6
-rw-r--r--app/models/packages/go/module.rb2
-rw-r--r--app/models/packages/helm.rb2
-rw-r--r--app/models/packages/nuget.rb1
-rw-r--r--app/models/packages/package.rb8
-rw-r--r--app/models/packages/package_file.rb2
-rw-r--r--app/models/plan.rb2
-rw-r--r--app/models/plan_limits.rb4
-rw-r--r--app/models/project.rb171
-rw-r--r--app/models/project_ci_cd_setting.rb1
-rw-r--r--app/models/project_services/mock_monitoring_service.rb23
-rw-r--r--app/models/project_services/monitoring_service.rb21
-rw-r--r--app/models/project_services/prometheus_service.rb203
-rw-r--r--app/models/project_team.rb15
-rw-r--r--app/models/repository_language.rb4
-rw-r--r--app/models/service_desk_setting.rb5
-rw-r--r--app/models/timelog.rb1
-rw-r--r--app/models/user.rb23
-rw-r--r--app/models/user_callout.rb6
-rw-r--r--app/models/wiki.rb36
-rw-r--r--app/models/wiki_page.rb1
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/policies/project_policy.rb24
-rw-r--r--app/policies/release_policy.rb16
-rw-r--r--app/policies/releases/link_policy.rb2
-rw-r--r--app/presenters/blob_presenter.rb4
-rw-r--r--app/presenters/ci/build_runner_presenter.rb2
-rw-r--r--app/presenters/commit_status_presenter.rb3
-rw-r--r--app/presenters/dev_ops_report/metric_presenter.rb2
-rw-r--r--app/presenters/gitlab/blame_presenter.rb2
-rw-r--r--app/presenters/merge_request_presenter.rb4
-rw-r--r--app/presenters/packages/helm/index_presenter.rb53
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb3
-rw-r--r--app/presenters/packages/nuget/service_index_presenter.rb10
-rw-r--r--app/presenters/project_presenter.rb4
-rw-r--r--app/presenters/search_service_presenter.rb2
-rw-r--r--app/presenters/snippet_blob_presenter.rb4
-rw-r--r--app/serializers/analytics/cycle_analytics/stage_entity.rb15
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/paginated_diff_entity.rb27
-rw-r--r--app/serializers/service_event_entity.rb12
-rw-r--r--app/services/alert_management/alerts/update_service.rb5
-rw-r--r--app/services/application_settings/update_service.rb4
-rw-r--r--app/services/audit_event_service.rb7
-rw-r--r--app/services/auth/container_registry_authentication_service.rb20
-rw-r--r--app/services/base_service.rb1
-rw-r--r--app/services/boards/issues/create_service.rb4
-rw-r--r--app/services/branches/create_service.rb8
-rw-r--r--app/services/bulk_create_integration_service.rb8
-rw-r--r--app/services/bulk_imports/file_download_service.rb68
-rw-r--r--app/services/bulk_update_integration_service.rb6
-rw-r--r--app/services/captcha/captcha_verification_service.rb25
-rw-r--r--app/services/ci/after_requeue_job_service.rb24
-rw-r--r--app/services/ci/append_build_trace_service.rb13
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb4
-rw-r--r--app/services/ci/destroy_pipeline_service.rb4
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb2
-rw-r--r--app/services/ci/job_token_scope/add_project_service.rb31
-rw-r--r--app/services/ci/job_token_scope/remove_project_service.rb31
-rw-r--r--app/services/ci/pipeline_schedules/calculate_next_run_service.rb9
-rw-r--r--app/services/ci/pipelines/add_job_service.rb37
-rw-r--r--app/services/ci/play_bridge_service.rb2
-rw-r--r--app/services/ci/play_build_service.rb2
-rw-r--r--app/services/ci/queue/build_queue_service.rb90
-rw-r--r--app/services/ci/queue/builds_table_strategy.rb67
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb69
-rw-r--r--app/services/ci/register_job_service.rb86
-rw-r--r--app/services/ci/retry_build_service.rb44
-rw-r--r--app/services/ci/retry_pipeline_service.rb16
-rw-r--r--app/services/concerns/alert_management/alert_processing.rb2
-rw-r--r--app/services/concerns/ci/job_token_scope/edit_scope_validations.rb26
-rw-r--r--app/services/concerns/update_repository_storage_methods.rb6
-rw-r--r--app/services/design_management/save_designs_service.rb2
-rw-r--r--app/services/error_tracking/collect_error_service.rb38
-rw-r--r--app/services/git/base_hooks_service.rb2
-rw-r--r--app/services/git/wiki_push_service/change.rb3
-rw-r--r--app/services/gpg_keys/create_service.rb10
-rw-r--r--app/services/gpg_keys/destroy_service.rb2
-rw-r--r--app/services/groups/group_links/create_service.rb2
-rw-r--r--app/services/groups/group_links/destroy_service.rb2
-rw-r--r--app/services/groups/group_links/update_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb5
-rw-r--r--app/services/incident_management/incidents/create_service.rb12
-rw-r--r--app/services/incident_management/incidents/update_severity_service.rb38
-rw-r--r--app/services/issuable/clone/base_service.rb2
-rw-r--r--app/services/issuable/import_csv/base_service.rb5
-rw-r--r--app/services/issuable_base_service.rb19
-rw-r--r--app/services/issues/base_service.rb2
-rw-r--r--app/services/issues/clone_service.rb6
-rw-r--r--app/services/issues/create_service.rb25
-rw-r--r--app/services/issues/duplicate_service.rb1
-rw-r--r--app/services/issues/move_service.rb5
-rw-r--r--app/services/issues/update_service.rb26
-rw-r--r--app/services/jira/requests/base.rb12
-rw-r--r--app/services/jira/requests/projects/list_service.rb4
-rw-r--r--app/services/jira_connect_installations/destroy_service.rb24
-rw-r--r--app/services/jira_import/users_importer.rb4
-rw-r--r--app/services/jira_import/users_mapper_service.rb8
-rw-r--r--app/services/keys/destroy_service.rb12
-rw-r--r--app/services/members/creator_service.rb172
-rw-r--r--app/services/members/groups/creator_service.rb17
-rw-r--r--app/services/members/invite_service.rb2
-rw-r--r--app/services/members/projects/creator_service.rb22
-rw-r--r--app/services/merge_requests/base_service.rb2
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb5
-rw-r--r--app/services/merge_requests/rebase_service.rb6
-rw-r--r--app/services/metrics_service.rb2
-rw-r--r--app/services/namespace_settings/update_service.rb25
-rw-r--r--app/services/namespaces/in_product_marketing_emails_service.rb17
-rw-r--r--app/services/notes/post_process_service.rb2
-rw-r--r--app/services/notification_service.rb5
-rw-r--r--app/services/packages/conan/search_service.rb2
-rw-r--r--app/services/packages/destroy_package_service.rb33
-rw-r--r--app/services/packages/nuget/metadata_extraction_service.rb6
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb21
-rw-r--r--app/services/post_receive_service.rb10
-rw-r--r--app/services/projects/create_service.rb27
-rw-r--r--app/services/projects/group_links/create_service.rb7
-rw-r--r--app/services/projects/group_links/update_service.rb20
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb12
-rw-r--r--app/services/projects/operations/update_service.rb6
-rw-r--r--app/services/projects/overwrite_project_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb2
-rw-r--r--app/services/projects/protect_default_branch_service.rb6
-rw-r--r--app/services/projects/transfer_service.rb14
-rw-r--r--app/services/projects/update_pages_service.rb14
-rw-r--r--app/services/projects/update_remote_mirror_service.rb2
-rw-r--r--app/services/projects/update_service.rb2
-rw-r--r--app/services/releases/base_service.rb11
-rw-r--r--app/services/releases/create_service.rb10
-rw-r--r--app/services/releases/destroy_service.rb2
-rw-r--r--app/services/releases/update_service.rb2
-rw-r--r--app/services/resource_access_tokens/create_service.rb9
-rw-r--r--app/services/security/ci_configuration/dependency_scanning_create_service.rb25
-rw-r--r--app/services/service_ping/build_payload_service.rb27
-rw-r--r--app/services/service_ping/permit_data_categories_service.rb34
-rw-r--r--app/services/service_ping/submit_service.rb94
-rw-r--r--app/services/snippets/create_service.rb30
-rw-r--r--app/services/snippets/update_service.rb29
-rw-r--r--app/services/spam/akismet_service.rb1
-rw-r--r--app/services/spam/spam_action_service.rb96
-rw-r--r--app/services/spam/spam_params.rb44
-rw-r--r--app/services/spam/spam_verdict_service.rb5
-rw-r--r--app/services/submit_usage_ping_service.rb77
-rw-r--r--app/services/user_agent_detail_service.rb17
-rw-r--r--app/services/user_project_access_changed_service.rb16
-rw-r--r--app/services/web_hook_service.rb1
-rw-r--r--app/services/wiki_pages/base_service.rb2
-rw-r--r--app/services/wiki_pages/create_service.rb5
-rw-r--r--app/services/wiki_pages/update_service.rb2
-rw-r--r--app/services/wikis/create_attachment_service.rb4
-rw-r--r--app/uploaders/dependency_proxy/file_uploader.rb2
-rw-r--r--app/validators/json_schemas/build_metadata_secrets.json3
-rw-r--r--app/validators/json_schemas/error_tracking_event_payload.json231
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml18
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml6
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml2
-rw-r--r--app/views/admin/application_settings/_localization.html.haml5
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml25
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml23
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml7
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml14
-rw-r--r--app/views/admin/application_settings/_runner_registrars_form.html.haml16
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml15
-rw-r--r--app/views/admin/application_settings/_third_party_offers.html.haml6
-rw-r--r--app/views/admin/application_settings/_usage.html.haml41
-rw-r--r--app/views/admin/application_settings/ci/_header.html.haml4
-rw-r--r--app/views/admin/application_settings/ci_cd.html.haml12
-rw-r--r--app/views/admin/application_settings/general.html.haml1
-rw-r--r--app/views/admin/application_settings/integrations.html.haml8
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml4
-rw-r--r--app/views/admin/application_settings/preferences.html.haml13
-rw-r--r--app/views/admin/application_settings/repository.html.haml12
-rw-r--r--app/views/admin/background_migrations/_migration.html.haml9
-rw-r--r--app/views/admin/background_migrations/index.html.haml1
-rw-r--r--app/views/admin/dashboard/index.html.haml5
-rw-r--r--app/views/admin/dev_ops_report/_callout.html.haml2
-rw-r--r--app/views/admin/dev_ops_report/_report.html.haml9
-rw-r--r--app/views/admin/groups/show.html.haml6
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml5
-rw-r--r--app/views/admin/projects/show.html.haml21
-rw-r--r--app/views/admin/runners/_runner.html.haml10
-rw-r--r--app/views/admin/runners/index.html.haml12
-rw-r--r--app/views/admin/users/_approve_user.html.haml7
-rw-r--r--app/views/admin/users/_ban_user.html.haml9
-rw-r--r--app/views/admin/users/_block_user.html.haml8
-rw-r--r--app/views/admin/users/_head.html.haml63
-rw-r--r--app/views/admin/users/_reject_pending_user.html.haml7
-rw-r--r--app/views/admin/users/_user_activation_effects.html.haml6
-rw-r--r--app/views/admin/users/_user_approve_effects.html.haml11
-rw-r--r--app/views/admin/users/_user_detail_note.html.haml4
-rw-r--r--app/views/admin/users/_user_reject_effects.html.haml10
-rw-r--r--app/views/admin/users/keys.html.haml1
-rw-r--r--app/views/admin/users/projects.html.haml2
-rw-r--r--app/views/admin/users/show.html.haml129
-rw-r--r--app/views/ci/token_access/_index.html.haml1
-rw-r--r--app/views/ci/variables/_content.html.haml4
-rw-r--r--app/views/ci/variables/_index.html.haml6
-rw-r--r--app/views/ci/variables/_variable_row.html.haml2
-rw-r--r--app/views/clusters/clusters/_applications.html.haml1
-rw-r--r--app/views/clusters/clusters/_applications_tab.html.haml5
-rw-r--r--app/views/clusters/clusters/_banner.html.haml18
-rw-r--r--app/views/clusters/clusters/_multiple_clusters_message.html.haml2
-rw-r--r--app/views/clusters/clusters/_provider_details_form.html.haml4
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml8
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml17
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml4
-rw-r--r--app/views/dashboard/projects/index.html.haml9
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/groups/_delete_project_button.html.haml1
-rw-r--r--app/views/groups/_home_panel.html.haml4
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml3
-rw-r--r--app/views/groups/_invite_members_modal.html.haml15
-rw-r--r--app/views/groups/_project_badges.html.haml2
-rw-r--r--app/views/groups/merge_requests.html.haml2
-rw-r--r--app/views/groups/milestones/_form.html.haml38
-rw-r--r--app/views/groups/projects.html.haml5
-rw-r--r--app/views/groups/runners/_group_runners.html.haml35
-rw-r--r--app/views/groups/runners/_runner.html.haml8
-rw-r--r--app/views/groups/runners/_settings.html.haml (renamed from app/views/groups/runners/_index.html.haml)0
-rw-r--r--app/views/groups/settings/_advanced.html.haml17
-rw-r--r--app/views/groups/settings/_pages_settings.html.haml2
-rw-r--r--app/views/groups/settings/_transfer.html.haml22
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/groups/settings/integrations/index.html.haml8
-rw-r--r--app/views/groups/settings/repository/_initial_branch_name.html.haml4
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/help/index.html.haml2
-rw-r--r--app/views/import/gitlab_projects/new.html.haml2
-rw-r--r--app/views/import/shared/_new_project_form.html.haml4
-rw-r--r--app/views/layouts/_flash.html.haml2
-rw-r--r--app/views/layouts/_loading_hints.html.haml2
-rw-r--r--app/views/layouts/_page.html.haml4
-rw-r--r--app/views/layouts/_search.html.haml4
-rw-r--r--app/views/layouts/_snowplow.html.haml2
-rw-r--r--app/views/layouts/application.html.haml1
-rw-r--r--app/views/layouts/devise.html.haml25
-rw-r--r--app/views/layouts/header/_new_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml4
-rw-r--r--app/views/layouts/minimal.html.haml18
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml2
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml21
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml20
-rw-r--r--app/views/layouts/nav/sidebar/_analytics_links.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_context_menu_body.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml210
-rw-r--r--app/views/layouts/nav/sidebar/_group_menus.html.haml166
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml7
-rw-r--r--app/views/layouts/welcome.html.haml8
-rw-r--r--app/views/notify/access_token_about_to_expire_email.html.haml2
-rw-r--r--app/views/notify/access_token_about_to_expire_email.text.erb2
-rw-r--r--app/views/notify/access_token_expired_email.html.haml2
-rw-r--r--app/views/notify/access_token_expired_email.text.erb2
-rw-r--r--app/views/notify/in_product_marketing_email.html.haml2
-rw-r--r--app/views/notify/send_admin_notification.html.haml7
-rw-r--r--app/views/notify/send_admin_notification.text.haml6
-rw-r--r--app/views/notify/send_unsubscribed_notification.html.haml2
-rw-r--r--app/views/notify/send_unsubscribed_notification.text.haml1
-rw-r--r--app/views/notify/user_deactivated_email.html.haml17
-rw-r--r--app/views/notify/user_deactivated_email.text.erb10
-rw-r--r--app/views/profiles/accounts/show.html.haml16
-rw-r--r--app/views/profiles/keys/index.html.haml4
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml3
-rw-r--r--app/views/profiles/preferences/show.html.haml46
-rw-r--r--app/views/profiles/show.html.haml5
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml3
-rw-r--r--app/views/projects/_flash_messages.html.haml1
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_import_project_pane.html.haml2
-rw-r--r--app/views/projects/_invite_members_empty_project.html.haml (renamed from app/views/projects/_invite_members.html.haml)3
-rw-r--r--app/views/projects/_invite_members_modal.html.haml15
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml2
-rw-r--r--app/views/projects/_remove.html.haml2
-rw-r--r--app/views/projects/_terraform_banner.html.haml5
-rw-r--r--app/views/projects/blame/show.html.haml5
-rw-r--r--app/views/projects/blob/_pipeline_tour_success.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_csv.html.haml1
-rw-r--r--app/views/projects/branches/new.html.haml8
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml4
-rw-r--r--app/views/projects/diffs/_warning.html.haml11
-rw-r--r--app/views/projects/empty.html.haml2
-rw-r--r--app/views/projects/feature_flags/edit.html.haml3
-rw-r--r--app/views/projects/forks/error.html.haml15
-rw-r--r--app/views/projects/forks/new.html.haml2
-rw-r--r--app/views/projects/import/jira/show.html.haml2
-rw-r--r--app/views/projects/issues/_alert_moved_from_service_desk.html.haml9
-rw-r--r--app/views/projects/issues/index.html.haml2
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/mattermosts/_no_teams.html.haml2
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml4
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/projects/milestones/_form.html.haml40
-rw-r--r--app/views/projects/milestones/show.html.haml24
-rw-r--r--app/views/projects/packages/packages/show.html.haml24
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml7
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/index.html.haml4
-rw-r--r--app/views/projects/prometheus/metrics/edit.html.haml2
-rw-r--r--app/views/projects/prometheus/metrics/new.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml13
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml12
-rw-r--r--app/views/projects/registry/settings/_index.haml9
-rw-r--r--app/views/projects/releases/index.html.haml2
-rw-r--r--app/views/projects/runners/_settings.html.haml (renamed from app/views/projects/runners/_index.html.haml)0
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml34
-rw-r--r--app/views/projects/security/configuration/show.html.haml2
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml7
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml12
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml22
-rw-r--r--app/views/projects/settings/operations/show.html.haml7
-rw-r--r--app/views/registrations/experience_levels/show.html.haml1
-rw-r--r--app/views/registrations/invites/new.html.haml17
-rw-r--r--app/views/registrations/welcome/show.html.haml5
-rw-r--r--app/views/root/index.html.haml10
-rw-r--r--app/views/search/_results.html.haml24
-rw-r--r--app/views/search/results/_blob_data.html.haml1
-rw-r--r--app/views/search/results/_issuable.html.haml33
-rw-r--r--app/views/search/results/_timeout.html.haml10
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml4
-rw-r--r--app/views/shared/_confirm_your_email_alert.html.haml7
-rw-r--r--app/views/shared/_global_alert.html.haml16
-rw-r--r--app/views/shared/_group_form.html.haml3
-rw-r--r--app/views/shared/_import_form.html.haml11
-rw-r--r--app/views/shared/_new_project_item_select.html.haml2
-rw-r--r--app/views/shared/_project_limit.html.haml16
-rw-r--r--app/views/shared/_service_ping_consent.html.haml (renamed from app/views/shared/_ping_consent.html.haml)8
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml29
-rw-r--r--app/views/shared/access_tokens/_table.html.haml17
-rw-r--r--app/views/shared/boards/_show.html.haml4
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/empty_states/_issues.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml12
-rw-r--r--app/views/shared/issuable/_form.html.haml15
-rw-r--r--app/views/shared/issuable/_invite_members_trigger.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml41
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml15
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml3
-rw-r--r--app/views/shared/members/_group.html.haml50
-rw-r--r--app/views/shared/members/_manage_access_button.html.haml7
-rw-r--r--app/views/shared/members/_member.html.haml72
-rw-r--r--app/views/shared/members/_requests.html.haml5
-rw-r--r--app/views/shared/members/_search_field.html.haml6
-rw-r--r--app/views/shared/members/_sort_dropdown.html.haml19
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml24
-rw-r--r--app/views/shared/milestones/_issuable.html.haml2
-rw-r--r--app/views/shared/milestones/_issuables.html.haml14
-rw-r--r--app/views/shared/milestones/_milestone_complete_alert.html.haml10
-rw-r--r--app/views/shared/milestones/_top.html.haml9
-rw-r--r--app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml3
-rw-r--r--app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml3
-rw-r--r--app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml3
-rw-r--r--app/views/shared/nav/_scope_menu.html.haml12
-rw-r--r--app/views/shared/nav/_scope_menu_body.html.haml8
-rw-r--r--app/views/shared/nav/_sidebar.html.haml13
-rw-r--r--app/views/shared/nav/_sidebar_menu.html.haml12
-rw-r--r--app/views/shared/nav/_sidebar_menu_collapsed.html.haml5
-rw-r--r--app/views/shared/snippets/_embed.html.haml4
-rw-r--r--app/views/shared/wikis/edit.html.haml2
-rw-r--r--app/views/users/unsubscribes/show.html.haml11
-rw-r--r--app/workers/all_queues.yml65
-rw-r--r--app/workers/archive_trace_worker.rb15
-rw-r--r--app/workers/authorized_project_update/user_refresh_from_replica_worker.rb47
-rw-r--r--app/workers/build_finished_worker.rb58
-rw-r--r--app/workers/build_queue_worker.rb2
-rw-r--r--app/workers/bulk_imports/export_request_worker.rb2
-rw-r--r--app/workers/ci/archive_trace_worker.rb18
-rw-r--r--app/workers/ci/archive_traces_cron_worker.rb2
-rw-r--r--app/workers/ci/build_finished_worker.rb71
-rw-r--r--app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb12
-rw-r--r--app/workers/clusters/applications/activate_service_worker.rb2
-rw-r--r--app/workers/clusters/applications/deactivate_service_worker.rb14
-rw-r--r--app/workers/concerns/application_worker.rb25
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb17
-rw-r--r--app/workers/concerns/waitable_worker.rb4
-rw-r--r--app/workers/concerns/worker_attributes.rb3
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb10
-rw-r--r--app/workers/container_expiration_policy_worker.rb21
-rw-r--r--app/workers/database/partition_management_worker.rb19
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/import_diff_note_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_issue_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_lfs_object_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_note_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_review_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_worker.rb8
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb3
-rw-r--r--app/workers/gitlab/import/stuck_import_job.rb2
-rw-r--r--app/workers/gitlab_service_ping_worker.rb (renamed from app/workers/gitlab_usage_ping_worker.rb)10
-rw-r--r--app/workers/jira_connect/forward_event_worker.rb25
-rw-r--r--app/workers/jira_connect/sync_branch_worker.rb5
-rw-r--r--app/workers/jira_connect/sync_builds_worker.rb8
-rw-r--r--app/workers/jira_connect/sync_deployments_worker.rb8
-rw-r--r--app/workers/jira_connect/sync_feature_flags_worker.rb8
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb4
-rw-r--r--app/workers/jira_connect/sync_project_worker.rb5
-rw-r--r--app/workers/merge_request_cleanup_refs_worker.rb56
-rw-r--r--app/workers/namespaces/in_product_marketing_emails_worker.rb5
-rw-r--r--app/workers/packages/helm/extraction_worker.rb29
-rw-r--r--app/workers/partition_creation_worker.rb5
-rw-r--r--app/workers/pipeline_hooks_worker.rb2
-rw-r--r--app/workers/project_service_worker.rb2
-rw-r--r--app/workers/projects/post_creation_worker.rb12
-rw-r--r--app/workers/prometheus/create_default_alerts_worker.rb2
-rw-r--r--app/workers/repository_check/single_repository_worker.rb2
-rw-r--r--app/workers/schedule_merge_request_cleanup_refs_worker.rb13
1533 files changed, 17941 insertions, 14190 deletions
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index b671d038ce8..f45af5fe08e 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -42,7 +42,7 @@ export default class Activities {
}
updateTooltips() {
- localTimeAgo($('.js-timeago', '.content_list'));
+ localTimeAgo(document.querySelectorAll('.content_list .js-timeago'));
}
reloadActivities() {
diff --git a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js
index a357d5d2f1f..cfa2f4b8762 100644
--- a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js
+++ b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js
@@ -1,3 +1,4 @@
+import initSetHelperText from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics';
import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer';
export default () => {
@@ -5,3 +6,5 @@ export default () => {
new PayloadPreviewer(trigger).init();
});
};
+
+initSetHelperText();
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 99c260bf11e..74e9c60a57b 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,6 +1,16 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
+
+// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
+const messageHtml = `
+ <p>${s__('AdminUsers|Reactivating a user will:')}</p>
+ <ul>
+ <li>${s__('AdminUsers|Restore user access to the account, including web, Git and API.')}</li>
+ </ul>
+ <p>${s__('AdminUsers|You can always deactivate their account again if needed.')}</p>
+`;
export default {
components: {
@@ -25,9 +35,14 @@ export default {
title: sprintf(s__('AdminUsers|Activate user %{username}?'), {
username: this.username,
}),
- message: s__('AdminUsers|You can always deactivate their account again if needed.'),
- okVariant: 'confirm',
- okTitle: s__('AdminUsers|Activate'),
+ messageHtml,
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.activate,
+ attributes: [{ variant: 'confirm' }],
+ },
}),
};
},
@@ -36,9 +51,7 @@ export default {
</script>
<template>
- <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <slot></slot>
- </gl-dropdown-item>
- </div>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 6fc43c246ea..77a9be8eec2 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,21 +1,60 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
+
+// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
+const messageHtml = `
+ <p>${s__('AdminUsers|Approved users can:')}</p>
+ <ul>
+ <li>${s__('AdminUsers|Log in')}</li>
+ <li>${s__('AdminUsers|Access Git repositories')}</li>
+ <li>${s__('AdminUsers|Access the API')}</li>
+ <li>${s__('AdminUsers|Be added to groups and projects')}</li>
+ </ul>
+`;
export default {
components: {
GlDropdownItem,
},
props: {
+ username: {
+ type: String,
+ required: true,
+ },
path: {
type: String,
required: true,
},
},
+ computed: {
+ attributes() {
+ return {
+ 'data-path': this.path,
+ 'data-method': 'put',
+ 'data-modal-attributes': JSON.stringify({
+ title: sprintf(s__('AdminUsers|Approve user %{username}?'), {
+ username: this.username,
+ }),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.approve,
+ attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }],
+ },
+ messageHtml,
+ }),
+ 'data-qa-selector': 'approve_user_button',
+ };
+ },
+ },
};
</script>
<template>
- <gl-dropdown-item :href="path" data-method="put">
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
new file mode 100644
index 00000000000..4e9cefbfdd7
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
+
+// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
+const messageHtml = `
+ <p>${s__('AdminUsers|When banned, users:')}</p>
+ <ul>
+ <li>${s__("AdminUsers|Can't log in.")}</li>
+ <li>${s__("AdminUsers|Can't access Git repositories.")}</li>
+ </ul>
+ <p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p>
+ <p>${sprintf(
+ s__('AdminUsers|Learn more about %{link_start}banned users.%{link_end}'),
+ {
+ link_start: `<a href="${helpPagePath('user/admin_area/moderate_users', {
+ anchor: 'ban-a-user',
+ })}" target="_blank">`,
+ link_end: '</a>',
+ },
+ false,
+ )}</p>
+`;
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ modalAttributes() {
+ return {
+ 'data-path': this.path,
+ 'data-method': 'put',
+ 'data-modal-attributes': JSON.stringify({
+ title: sprintf(s__('AdminUsers|Ban user %{username}?'), {
+ username: this.username,
+ }),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.ban,
+ attributes: [{ variant: 'confirm' }],
+ },
+ messageHtml,
+ }),
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index 68dfefe14c2..03557008a89 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
const messageHtml = `
@@ -11,6 +12,7 @@ const messageHtml = `
<li>${s__('AdminUsers|Personal projects will be left')}</li>
<li>${s__('AdminUsers|Owned groups will be left')}</li>
</ul>
+ <p>${s__('AdminUsers|You can always unblock their account, their data will remain intact.')}</p>
`;
export default {
@@ -34,8 +36,13 @@ export default {
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }),
- okVariant: 'confirm',
- okTitle: s__('AdminUsers|Block'),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.block,
+ attributes: [{ variant: 'confirm' }],
+ },
messageHtml,
}),
};
@@ -45,9 +52,7 @@ export default {
</script>
<template>
- <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <slot></slot>
- </gl-dropdown-item>
- </div>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index 7e0c17ba296..640c8fefc20 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
const messageHtml = `
@@ -16,6 +17,9 @@ const messageHtml = `
)}</li>
<li>${s__('AdminUsers|Personal projects, group and user history will be left intact')}</li>
</ul>
+ <p>${s__(
+ 'AdminUsers|You can always re-activate their account, their data will remain intact.',
+ )}</p>
`;
export default {
@@ -41,8 +45,13 @@ export default {
title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), {
username: this.username,
}),
- okVariant: 'confirm',
- okTitle: s__('AdminUsers|Deactivate'),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.deactivate,
+ attributes: [{ variant: 'confirm' }],
+ },
messageHtml,
}),
};
@@ -52,9 +61,7 @@ export default {
</script>
<template>
- <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <slot></slot>
- </gl-dropdown-item>
- </div>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js
index e34b01346b9..4e63a85df89 100644
--- a/app/assets/javascripts/admin/users/components/actions/index.js
+++ b/app/assets/javascripts/admin/users/components/actions/index.js
@@ -1,20 +1,24 @@
import Activate from './activate.vue';
import Approve from './approve.vue';
+import Ban from './ban.vue';
import Block from './block.vue';
import Deactivate from './deactivate.vue';
import Delete from './delete.vue';
import DeleteWithContributions from './delete_with_contributions.vue';
import Reject from './reject.vue';
+import Unban from './unban.vue';
import Unblock from './unblock.vue';
import Unlock from './unlock.vue';
export default {
Activate,
Approve,
+ Ban,
Block,
Deactivate,
Delete,
DeleteWithContributions,
+ Unban,
Unblock,
Unlock,
Reject,
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index a80c1ff5458..901306455fa 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -1,21 +1,70 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
+
+// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
+const messageHtml = `
+ <p>${s__('AdminUsers|Rejected users:')}</p>
+ <ul>
+ <li>${s__('AdminUsers|Cannot sign in or access instance information')}</li>
+ <li>${s__('AdminUsers|Will be deleted')}</li>
+ </ul>
+ <p>${sprintf(
+ s__(
+ 'AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}',
+ ),
+ {
+ link_start: `<a href="${helpPagePath('user/profile/account/delete_account', {
+ anchor: 'associated-records',
+ })}" target="_blank">`,
+ link_end: '</a>',
+ },
+ false,
+ )}</p>
+`;
export default {
components: {
GlDropdownItem,
},
props: {
+ username: {
+ type: String,
+ required: true,
+ },
path: {
type: String,
required: true,
},
},
+ computed: {
+ modalAttributes() {
+ return {
+ 'data-path': this.path,
+ 'data-method': 'delete',
+ 'data-modal-attributes': JSON.stringify({
+ title: sprintf(s__('AdminUsers|Reject user %{username}?'), {
+ username: this.username,
+ }),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.reject,
+ attributes: [{ variant: 'danger' }],
+ },
+ messageHtml,
+ }),
+ };
+ },
+ },
};
</script>
<template>
- <gl-dropdown-item :href="path" data-method="delete">
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<slot></slot>
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
new file mode 100644
index 00000000000..8083e26177e
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
+
+// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
+const messageHtml = `<p>${s__(
+ 'AdminUsers|You can ban their account in the future if necessary.',
+)}</p>`;
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ modalAttributes() {
+ return {
+ 'data-path': this.path,
+ 'data-method': 'put',
+ 'data-modal-attributes': JSON.stringify({
+ title: sprintf(s__('AdminUsers|Unban user %{username}?'), {
+ username: this.username,
+ }),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.unban,
+ attributes: [{ variant: 'confirm' }],
+ },
+ messageHtml,
+ }),
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index d4c0f900c94..7de6653e0cd 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
@@ -24,8 +25,13 @@ export default {
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }),
message: s__('AdminUsers|You can always block their account again if needed.'),
- okVariant: 'confirm',
- okTitle: s__('AdminUsers|Unblock'),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.unblock,
+ attributes: [{ variant: 'confirm' }],
+ },
}),
};
},
@@ -34,9 +40,7 @@ export default {
</script>
<template>
- <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <slot></slot>
- </gl-dropdown-item>
- </div>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index 294aaade7c1..10d4fb06d61 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,6 +1,7 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
+import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
@@ -24,8 +25,13 @@ export default {
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }),
message: __('Are you sure?'),
- okVariant: 'confirm',
- okTitle: s__('AdminUsers|Unlock'),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ actionPrimary: {
+ text: I18N_USER_ACTIONS.unlock,
+ attributes: [{ variant: 'confirm' }],
+ },
}),
};
},
@@ -34,9 +40,7 @@ export default {
</script>
<template>
- <div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
- <gl-dropdown-item>
- <slot></slot>
- </gl-dropdown-item>
- </div>
+ <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
+ <slot></slot>
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
index a3b78da6ef5..413163c8536 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
@@ -58,7 +58,7 @@ export default {
},
computed: {
modalTitle() {
- return sprintf(this.title, { username: this.username });
+ return sprintf(this.title, { username: this.username }, false);
},
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
@@ -112,7 +112,7 @@ export default {
</gl-sprintf>
</p>
- <oncall-schedules-list v-if="schedules.length" :schedules="schedules" />
+ <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" />
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue
index 1dfea3f1e7b..1dfea3f1e7b 100644
--- a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
+++ b/app/assets/javascripts/admin/users/components/modals/user_modal_manager.vue
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index b782526e6be..c076e0bedf0 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -5,6 +5,7 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
@@ -21,6 +22,9 @@ export default {
GlDropdownDivider,
...Actions,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
user: {
type: Object,
@@ -30,6 +34,11 @@ export default {
type: Object,
required: true,
},
+ showButtonLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
userActions() {
@@ -56,6 +65,13 @@ export default {
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
+ editButtonAttrs() {
+ return {
+ 'data-testid': 'edit',
+ icon: 'pencil-square',
+ href: this.userPaths.edit,
+ };
+ },
},
methods: {
isLdapAction(action) {
@@ -70,51 +86,68 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-justify-content-end" :data-testid="`user-actions-${user.id}`">
- <gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{
- $options.i18n.edit
- }}</gl-button>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2"
+ :data-testid="`user-actions-${user.id}`"
+ >
+ <div v-if="hasEditAction" class="gl-p-2">
+ <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs">{{
+ $options.i18n.edit
+ }}</gl-button>
+ <gl-button
+ v-else
+ v-gl-tooltip="$options.i18n.edit"
+ v-bind="editButtonAttrs"
+ :aria-label="$options.i18n.edit"
+ />
+ </div>
- <gl-dropdown
- v-if="hasDropdownActions"
- data-testid="dropdown-toggle"
- right
- class="gl-ml-2"
- icon="settings"
- >
- <gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header>
+ <div v-if="hasDropdownActions" class="gl-p-2">
+ <gl-dropdown
+ data-testid="dropdown-toggle"
+ right
+ :text="$options.i18n.userAdministration"
+ :text-sr-only="!showButtonLabels"
+ icon="settings"
+ data-qa-selector="user_actions_dropdown_toggle"
+ :data-qa-username="user.username"
+ >
+ <gl-dropdown-section-header>{{
+ $options.i18n.userAdministration
+ }}</gl-dropdown-section-header>
- <template v-for="action in dropdownSafeActions">
- <component
- :is="getActionComponent(action)"
- v-if="getActionComponent(action)"
- :key="action"
- :path="userPaths[action]"
- :username="user.name"
- :data-testid="action"
- >
- {{ $options.i18n[action] }}
- </component>
- <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
- {{ $options.i18n[action] }}
- </gl-dropdown-item>
- </template>
+ <template v-for="action in dropdownSafeActions">
+ <component
+ :is="getActionComponent(action)"
+ v-if="getActionComponent(action)"
+ :key="action"
+ :path="userPaths[action]"
+ :username="user.name"
+ :data-testid="action"
+ >
+ {{ $options.i18n[action] }}
+ </component>
+ <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
+ {{ $options.i18n[action] }}
+ </gl-dropdown-item>
+ </template>
- <gl-dropdown-divider v-if="hasDeleteActions" />
+ <gl-dropdown-divider v-if="hasDeleteActions" />
- <template v-for="action in dropdownDeleteActions">
- <component
- :is="getActionComponent(action)"
- v-if="getActionComponent(action)"
- :key="action"
- :paths="userPaths"
- :username="user.name"
- :oncall-schedules="user.oncallSchedules"
- :data-testid="`delete-${action}`"
- >
- {{ $options.i18n[action] }}
- </component>
- </template>
- </gl-dropdown>
+ <template v-for="action in dropdownDeleteActions">
+ <component
+ :is="getActionComponent(action)"
+ v-if="getActionComponent(action)"
+ :key="action"
+ :paths="userPaths"
+ :username="user.name"
+ :oncall-schedules="user.oncallSchedules"
+ :data-testid="`delete-${action}`"
+ >
+ {{ $options.i18n[action] }}
+ </component>
+ </template>
+ </gl-dropdown>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js
index c55edefe607..4636c8705a5 100644
--- a/app/assets/javascripts/admin/users/constants.js
+++ b/app/assets/javascripts/admin/users/constants.js
@@ -6,7 +6,7 @@ export const LENGTH_OF_USER_NOTE_TOOLTIP = 100;
export const I18N_USER_ACTIONS = {
edit: __('Edit'),
- settings: __('Settings'),
+ userAdministration: s__('AdminUsers|User administration'),
unlock: __('Unlock'),
block: s__('AdminUsers|Block'),
unblock: s__('AdminUsers|Unblock'),
@@ -17,4 +17,12 @@ export const I18N_USER_ACTIONS = {
ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'),
delete: s__('AdminUsers|Delete user'),
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
+ ban: s__('AdminUsers|Ban user'),
+ unban: s__('AdminUsers|Unban user'),
};
+
+export const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button';
+
+export const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
+
+export const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js
index 54c8edc080b..852b253d25a 100644
--- a/app/assets/javascripts/admin/users/index.js
+++ b/app/assets/javascripts/admin/users/index.js
@@ -2,7 +2,15 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import csrf from '~/lib/utils/csrf';
import AdminUsersApp from './components/app.vue';
+import ModalManager from './components/modals/user_modal_manager.vue';
+import UserActions from './components/user_actions.vue';
+import {
+ CONFIRM_DELETE_BUTTON_SELECTOR,
+ MODAL_TEXTS_CONTAINER_SELECTOR,
+ MODAL_MANAGER_SELECTOR,
+} from './constants';
Vue.use(VueApollo);
@@ -10,22 +18,71 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
-export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => {
+const initApp = (el, component, userPropKey, props = {}) => {
if (!el) {
return false;
}
- const { users, paths } = el.dataset;
+ const { [userPropKey]: user, paths } = el.dataset;
return new Vue({
el,
apolloProvider,
render: (createElement) =>
- createElement(AdminUsersApp, {
+ createElement(component, {
props: {
- users: convertObjectPropsToCamelCase(JSON.parse(users), { deep: true }),
+ [userPropKey]: convertObjectPropsToCamelCase(JSON.parse(user), { deep: true }),
paths: convertObjectPropsToCamelCase(JSON.parse(paths)),
+ ...props,
},
}),
});
};
+
+export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) =>
+ initApp(el, AdminUsersApp, 'users');
+
+export const initAdminUserActions = (el = document.querySelector('#js-admin-user-actions')) =>
+ initApp(el, UserActions, 'user', { showButtonLabels: true });
+
+export const initDeleteUserModals = () => {
+ const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR);
+
+ if (!modalsMountElement) {
+ return;
+ }
+
+ const modalConfiguration = Array.from(modalsMountElement.children).reduce((accumulator, node) => {
+ const { modal, ...config } = node.dataset;
+
+ return {
+ ...accumulator,
+ [modal]: {
+ title: node.dataset.title,
+ ...config,
+ content: node.innerHTML,
+ },
+ };
+ }, {});
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: MODAL_MANAGER_SELECTOR,
+ functional: true,
+ methods: {
+ show(...args) {
+ this.$refs.manager.show(...args);
+ },
+ },
+ render(h) {
+ return h(ModalManager, {
+ ref: 'manager',
+ props: {
+ selector: CONFIRM_DELETE_BUTTON_SELECTOR,
+ modalConfiguration,
+ csrfToken: csrf.token,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue
index c0ad814172d..7c14cf3767f 100644
--- a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue
@@ -25,28 +25,33 @@ export default {
};
</script>
<template>
- <gl-empty-state class="js-empty-state" :title="__('Usage ping is off')" :svg-path="svgPath">
+ <gl-empty-state :title="s__('ServicePing|Service ping is off')" :svg-path="svgPath">
<template #description>
<gl-sprintf
v-if="!isAdmin"
:message="
- __(
- 'To view instance-level analytics, ask an admin to turn on %{docLinkStart}usage ping%{docLinkEnd}.',
+ s__(
+ 'ServicePing|To view instance-level analytics, ask an admin to turn on %{docLinkStart}service ping%{docLinkEnd}.',
)
"
>
<template #docLink="{ content }">
- <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="docsLink" target="_blank" data-testid="docs-link">{{ content }}</gl-link>
</template>
</gl-sprintf>
- <template v-else
- ><p>
- {{ __('Turn on usage ping to review instance-level analytics.') }}
+ <template v-else>
+ <p>
+ {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }}
</p>
- <gl-button category="primary" variant="success" :href="primaryButtonPath">
- {{ __('Turn on usage ping') }}</gl-button
+ <gl-button
+ category="primary"
+ variant="success"
+ :href="primaryButtonPath"
+ data-testid="power-on-button"
>
+ {{ s__('ServicePing|Turn on service ping') }}
+ </gl-button>
</template>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js
index 0131407e723..63b36f35247 100644
--- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js
+++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js
@@ -1,27 +1,33 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
-import UsagePingDisabled from './components/usage_ping_disabled.vue';
+import ServicePingDisabled from './components/service_ping_disabled.vue';
export default () => {
// eslint-disable-next-line no-new
new UserCallout();
- const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled');
+ const emptyStateContainer = document.getElementById('js-devops-service-ping-disabled');
if (!emptyStateContainer) return false;
- const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset;
+ const {
+ isAdmin,
+ emptyStateSvgPath,
+ enableServicePingPath,
+ docsLink,
+ } = emptyStateContainer.dataset;
return new Vue({
el: emptyStateContainer,
provide: {
- isAdmin: Boolean(isAdmin),
+ isAdmin: parseBoolean(isAdmin),
svgPath: emptyStateSvgPath,
- primaryButtonPath: enableUsagePingLink,
+ primaryButtonPath: enableServicePingPath,
docsLink,
},
render(h) {
- return h(UsagePingDisabled);
+ return h(ServicePingDisabled);
},
});
};
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
new file mode 100644
index 00000000000..a5b9c40b9c9
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { getDayDifference } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import { OFFSET_DATE_BY_ONE } from '../constants';
+
+export default {
+ components: {
+ GlDaterangePicker,
+ GlSprintf,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ startDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ endDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ minDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ maxDate: {
+ type: Date,
+ required: false,
+ default() {
+ return new Date();
+ },
+ },
+ maxDateRange: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ includeSelectedDate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ maxDateRangeTooltip: sprintf(
+ __(
+ 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.',
+ ),
+ {
+ maxDateRange: this.maxDateRange,
+ },
+ ),
+ };
+ },
+ computed: {
+ dateRange: {
+ get() {
+ return { startDate: this.startDate, endDate: this.endDate };
+ },
+ set({ startDate, endDate }) {
+ this.$emit('change', { startDate, endDate });
+ },
+ },
+ numberOfDays() {
+ const dayDifference = getDayDifference(this.startDate, this.endDate);
+ return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ v-if="show"
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row align-items-lg-center justify-content-lg-end"
+ >
+ <gl-daterange-picker
+ v-model="dateRange"
+ class="d-flex flex-column flex-lg-row"
+ :default-start-date="startDate"
+ :default-end-date="endDate"
+ :default-min-date="minDate"
+ :max-date-range="maxDateRange"
+ :default-max-date="maxDate"
+ :same-day-selection="includeSelectedDate"
+ theme="animate-picker"
+ start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
+ end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
+ label-class="gl-mb-2 gl-lg-mb-0"
+ />
+ <div
+ v-if="maxDateRange"
+ class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center"
+ >
+ <span class="number-of-days pl-2 pr-1">
+ <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
+ <template #numberOfDays>{{ numberOfDays }}</template>
+ </gl-sprintf>
+ </span>
+ <gl-icon
+ v-gl-tooltip
+ data-testid="helper-icon"
+ :title="maxDateRangeTooltip"
+ name="question"
+ :size="14"
+ class="text-secondary"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue
deleted file mode 100644
index e6e12821bec..00000000000
--- a/app/assets/javascripts/analytics/shared/components/metric_card.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import {
- GlCard,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlLink,
- GlIcon,
- GlTooltipDirective,
-} from '@gitlab/ui';
-
-export default {
- name: 'MetricCard',
- components: {
- GlCard,
- GlSkeletonLoading,
- GlLink,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- title: {
- type: String,
- required: true,
- },
- metrics: {
- type: Array,
- required: true,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- methods: {
- valueText(metric) {
- const { value = null, unit = null } = metric;
- if (!value || value === '-') return '-';
- return unit && value ? `${value} ${unit}` : value;
- },
- },
-};
-</script>
-<template>
- <gl-card class="gl-mb-5">
- <template #header>
- <strong ref="title">{{ title }}</strong>
- </template>
- <template #default>
- <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" />
- <div v-else ref="metricsWrapper" class="gl-display-flex">
- <div
- v-for="metric in metrics"
- :key="metric.key"
- ref="metricItem"
- class="js-metric-card-item gl-flex-grow-1 gl-text-center"
- >
- <gl-link v-if="metric.link" :href="metric.link">
- <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3>
- </gl-link>
- <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3>
- <p class="text-secondary gl-font-sm gl-mb-2">
- {{ metric.label }}
- <span v-if="metric.tooltipText">
- &nbsp;
- <gl-icon
- v-gl-tooltip="{ title: metric.tooltipText }"
- :size="14"
- class="gl-vertical-align-middle"
- name="question"
- data-testid="tooltip"
- />
- </span>
- </p>
- </div>
- </div>
- </template>
- </gl-card>
-</template>
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
new file mode 100644
index 00000000000..a490111e13b
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -0,0 +1,241 @@
+<script>
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlAvatar,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { filterBySearchTerm } from '~/analytics/shared/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { n__, s__, __ } from '~/locale';
+import getProjects from '../graphql/projects.query.graphql';
+
+export default {
+ name: 'ProjectsDropdownFilter',
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlAvatar,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ groupNamespace: {
+ type: String,
+ required: true,
+ },
+ multiSelect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: s__('CycleAnalytics|project dropdown filter'),
+ },
+ queryParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ defaultProjects: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ projects: [],
+ selectedProjects: this.defaultProjects || [],
+ searchTerm: '',
+ isDirty: false,
+ };
+ },
+ computed: {
+ selectedProjectsLabel() {
+ if (this.selectedProjects.length === 1) {
+ return this.selectedProjects[0].name;
+ } else if (this.selectedProjects.length > 1) {
+ return n__(
+ 'CycleAnalytics|Project selected',
+ 'CycleAnalytics|%d projects selected',
+ this.selectedProjects.length,
+ );
+ }
+
+ return this.selectedProjectsPlaceholder;
+ },
+ selectedProjectsPlaceholder() {
+ return this.multiSelect ? __('Select projects') : __('Select a project');
+ },
+ isOnlyOneProjectSelected() {
+ return this.selectedProjects.length === 1;
+ },
+ selectedProjectIds() {
+ return this.selectedProjects.map((p) => p.id);
+ },
+ availableProjects() {
+ return filterBySearchTerm(this.projects, this.searchTerm);
+ },
+ noResultsAvailable() {
+ const { loading, availableProjects } = this;
+ return !loading && !availableProjects.length;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.search();
+ },
+ },
+ mounted() {
+ this.search();
+ },
+ methods: {
+ search: debounce(function debouncedSearch() {
+ this.fetchData();
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getSelectedProjects(selectedProject, isMarking) {
+ return isMarking
+ ? this.selectedProjects.concat([selectedProject])
+ : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
+ },
+ singleSelectedProject(selectedObj, isMarking) {
+ return isMarking ? [selectedObj] : [];
+ },
+ setSelectedProjects(selectedObj, isMarking) {
+ this.selectedProjects = this.multiSelect
+ ? this.getSelectedProjects(selectedObj, isMarking)
+ : this.singleSelectedProject(selectedObj, isMarking);
+ },
+ onClick({ project, isSelected }) {
+ this.setSelectedProjects(project, !isSelected);
+ this.$emit('selected', this.selectedProjects);
+ },
+ onMultiSelectClick({ project, isSelected }) {
+ this.setSelectedProjects(project, !isSelected);
+ this.isDirty = true;
+ },
+ onSelected(ev) {
+ if (this.multiSelect) {
+ this.onMultiSelectClick(ev);
+ } else {
+ this.onClick(ev);
+ }
+ },
+ onHide() {
+ if (this.multiSelect && this.isDirty) {
+ this.$emit('selected', this.selectedProjects);
+ }
+ this.searchTerm = '';
+ this.isDirty = false;
+ },
+ fetchData() {
+ this.loading = true;
+
+ return this.$apollo
+ .query({
+ query: getProjects,
+ variables: {
+ groupFullPath: this.groupNamespace,
+ search: this.searchTerm,
+ ...this.queryParams,
+ },
+ })
+ .then((response) => {
+ const {
+ data: {
+ group: {
+ projects: { nodes },
+ },
+ },
+ } = response;
+
+ this.loading = false;
+ this.projects = nodes;
+ });
+ },
+ isProjectSelected(id) {
+ return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
+ },
+ getEntityId(project) {
+ return getIdFromGraphQLId(project.id);
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ ref="projectsDropdown"
+ class="dropdown dropdown-projects"
+ toggle-class="gl-shadow-none"
+ @hide="onHide"
+ >
+ <template #button-content>
+ <div class="gl-display-flex gl-flex-grow-1">
+ <gl-avatar
+ v-if="isOnlyOneProjectSelected"
+ :src="selectedProjects[0].avatarUrl"
+ :entity-id="getEntityId(selectedProjects[0])"
+ :entity-name="selectedProjects[0].name"
+ :size="16"
+ shape="rect"
+ :alt="selectedProjects[0].name"
+ class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2"
+ />
+ {{ selectedProjectsLabel }}
+ </div>
+ <gl-icon class="gl-ml-2" name="chevron-down" />
+ </template>
+ <template #header>
+ <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <gl-dropdown-item
+ v-for="project in availableProjects"
+ :key="project.id"
+ :is-check-item="true"
+ :is-checked="isProjectSelected(project.id)"
+ @click.native.capture.stop="
+ onSelected({ project, isSelected: isProjectSelected(project.id) })
+ "
+ >
+ <div class="gl-display-flex">
+ <gl-avatar
+ class="gl-mr-2 vertical-align-middle"
+ :alt="project.name"
+ :size="16"
+ :entity-id="getEntityId(project)"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ shape="rect"
+ />
+ <div>
+ <div data-testid="project-name">{{ project.name }}</div>
+ <div class="gl-text-gray-500" data-testid="project-full-path">
+ {{ project.fullPath }}
+ </div>
+ </div>
+ </div>
+ </gl-dropdown-item>
+ <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{
+ __('No matching results')
+ }}</gl-dropdown-item>
+ <gl-dropdown-item v-if="loading">
+ <gl-loading-icon size="lg" />
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
new file mode 100644
index 00000000000..44d9b4b4262
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -0,0 +1,12 @@
+import { masks } from 'dateformat';
+
+export const DATE_RANGE_LIMIT = 180;
+export const OFFSET_DATE_BY_ONE = 1;
+export const PROJECTS_PER_PAGE = 50;
+
+const { isoDate, mediumDate } = masks;
+export const dateFormats = {
+ isoDate,
+ defaultDate: mediumDate,
+ defaultDateTime: 'mmm d, yyyy h:MMtt',
+};
diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
new file mode 100644
index 00000000000..63e95d6804c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
@@ -0,0 +1,22 @@
+query getGroupProjects(
+ $groupFullPath: ID!
+ $search: String!
+ $first: Int!
+ $includeSubgroups: Boolean = false
+) {
+ group(fullPath: $groupFullPath) {
+ projects(
+ search: $search
+ first: $first
+ includeSubgroups: $includeSubgroups
+ sort: SIMILARITY
+ ) {
+ nodes {
+ id
+ name
+ avatarUrl
+ fullPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
new file mode 100644
index 00000000000..84189b675f2
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -0,0 +1,4 @@
+export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
+ if (!searchTerm?.length) return data;
+ return data.filter((item) => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase()));
+};
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 0b4fa879b03..1eb4832a2a3 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,5 +1,6 @@
<script>
-import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
import createFlash from '~/flash';
import { number } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
@@ -10,7 +11,8 @@ const defaultPrecision = 0;
export default {
name: 'UsageCounts',
components: {
- MetricCard,
+ GlSkeletonLoading,
+ GlSingleStat,
},
data() {
return {
@@ -56,10 +58,24 @@ export default {
</script>
<template>
- <metric-card
- :title="__('Usage Trends')"
- :metrics="counts"
- :is-loading="$apollo.queries.counts.loading"
- class="gl-mt-4"
- />
+ <div>
+ <h2>
+ {{ __('Usage Trends') }}
+ </h2>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-my-6 gl-align-items-flex-start"
+ >
+ <gl-skeleton-loading v-if="$apollo.queries.counts.loading" />
+ <template v-else>
+ <gl-single-stat
+ v-for="count in counts"
+ :key="count.key"
+ class="gl-pr-9 gl-my-4 gl-md-mt-0 gl-md-mb-0"
+ :value="`${count.value}`"
+ :title="count.label"
+ :should-animate="true"
+ />
+ </template>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 41cc2036a6b..84a5d5ae4b3 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -3,7 +3,7 @@ import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { joinPaths } from './lib/utils/url_utility';
-const DEFAULT_PER_PAGE = 20;
+export const DEFAULT_PER_PAGE = 20;
/**
* Slow deprecation Notice: Please rather use for new calls
@@ -83,8 +83,8 @@ const Api = {
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id',
- usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
- usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
+ serviceDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
+ serviceDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/',
@@ -875,7 +875,7 @@ const Api = {
return null;
}
- const url = Api.buildUrl(this.usageDataIncrementCounterPath);
+ const url = Api.buildUrl(this.serviceDataIncrementCounterPath);
const headers = {
'Content-Type': 'application/json',
};
@@ -888,7 +888,7 @@ const Api = {
return null;
}
- const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath);
+ const url = Api.buildUrl(this.serviceDataIncrementUniqueUsersPath);
const headers = {
'Content-Type': 'application/json',
};
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index 58494c5a2b8..fd9b0160b0d 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -1,6 +1,8 @@
import axios from '~/lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
+const GROUP_VSA_PATH_BASE =
+ '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id';
const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
@@ -13,6 +15,12 @@ const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => {
return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath);
};
+const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) =>
+ buildApiUrl(GROUP_VSA_PATH_BASE)
+ .replace(':id', groupId)
+ .replace(':value_stream_id', valueStreamId)
+ .replace(':stage_id', stageId);
+
export const getProjectValueStreams = (projectPath) => {
const url = buildProjectValueStreamPath(projectPath);
return axios.get(url);
@@ -30,3 +38,14 @@ export const getProjectValueStreamStageData = ({ requestPath, stageId, params })
export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params });
+
+/**
+ * Shared group VSA paths
+ * We share some endpoints across and group and project level VSA
+ * When used for project level VSA, requests should include the `project_id` in the params object
+ */
+
+export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => {
+ const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId });
+ return axios.get(`${stageBase}/median`, { params });
+};
diff --git a/app/assets/javascripts/api/constants.js b/app/assets/javascripts/api/constants.js
deleted file mode 100644
index b6c720a85f3..00000000000
--- a/app/assets/javascripts/api/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export const DEFAULT_PER_PAGE = 20;
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index d6c9e1d42cc..a563afc6abb 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -1,6 +1,6 @@
+import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
-import { DEFAULT_PER_PAGE } from './constants';
const GROUPS_PATH = '/api/:version/groups.json';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index d9a2467cff3..1cd7fb0b954 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -1,6 +1,6 @@
+import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
-import { DEFAULT_PER_PAGE } from './constants';
const PROJECTS_PATH = '/api/:version/projects.json';
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 27901120c53..09995fad628 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,8 +1,8 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import { DEFAULT_PER_PAGE } from '~/api';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
-import { DEFAULT_PER_PAGE } from './constants';
const USER_COUNTS_PATH = '/api/:version/user_counts';
const USERS_PATH = '/api/:version/users.json';
@@ -52,7 +52,11 @@ export function getUserProjects(userId, query, options, callback) {
params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
- .catch(() => flash(__('Something went wrong while fetching projects')));
+ .catch(() =>
+ createFlash({
+ message: __('Something went wrong while fetching projects'),
+ }),
+ );
}
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 43f44370af8..43ca5b5cf89 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -7,7 +7,7 @@ import { uniq } from 'lodash';
import * as Emoji from '~/emoji';
import { scrollToElement } from '~/lib/utils/common_utils';
import { dispose, fixTitle } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
@@ -488,7 +488,11 @@ export class AwardsHandler {
callback();
}
})
- .catch(() => flash(__('Something went wrong on our end.')));
+ .catch(() =>
+ createFlash({
+ message: __('Something went wrong on our end.'),
+ }),
+ );
}
findEmojiIcon(votesBlock, emoji) {
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 309af368df9..53469ac8999 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -84,7 +84,7 @@ export default {
/>
</a>
- <gl-loading-icon v-show="isLoading" :inline="true" />
+ <gl-loading-icon v-show="isLoading" size="sm" :inline="true" />
<div v-show="hasError" class="btn-group">
<div class="btn btn-default btn-sm disabled">
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 7c4ff830a9d..7e605099655 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -221,7 +221,7 @@ export default {
:link-url="renderedLinkUrl"
/>
<p v-show="isRendering">
- <gl-loading-icon :inline="true" />
+ <gl-loading-icon size="sm" :inline="true" />
</p>
<p v-show="!renderedBadge && !isRendering" class="disabled-content">
{{ s__('Badges|No image to preview') }}
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index fda51c98e2c..d8525c15087 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -73,7 +73,7 @@ export default {
data-testid="delete-badge"
@click="updateBadgeInModal(badge)"
/>
- <gl-loading-icon v-show="badge.isDeleting" :inline="true" />
+ <gl-loading-icon v-show="badge.isDeleting" size="sm" :inline="true" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index e6de724512f..96c3b8276ee 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -94,9 +94,11 @@ export default {
@handleUpdateNote="update"
@toggleResolveStatus="toggleResolveDiscussion(draft.id)"
>
- <strong slot="note-header-info" class="badge draft-pending-label gl-mr-2">
- {{ __('Pending') }}
- </strong>
+ <template #note-header-info>
+ <strong class="badge draft-pending-label gl-mr-2">
+ {{ __('Pending') }}
+ </strong>
+ </template>
</noteable-note>
</ul>
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 9ffc5ee34cf..080a5543e53 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -26,7 +26,7 @@ export default {
</script>
<template>
<div v-show="draftsCount > 0">
- <nav class="review-bar-component">
+ <nav class="review-bar-component" data-testid="review_bar_component">
<div
class="review-bar-content d-flex gl-justify-content-end"
data-qa-selector="review_bar_content"
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index a8c0b064595..4ee22918463 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,5 +1,5 @@
import { isEmpty } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
@@ -18,7 +18,9 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
return res;
})
.catch(() => {
- flash(__('An error occurred adding a draft to the thread.'));
+ createFlash({
+ message: __('An error occurred adding a draft to the thread.'),
+ });
});
export const createNewDraft = ({ commit }, { endpoint, data }) =>
@@ -30,7 +32,9 @@ export const createNewDraft = ({ commit }, { endpoint, data }) =>
return res;
})
.catch(() => {
- flash(__('An error occurred adding a new draft.'));
+ createFlash({
+ message: __('An error occurred adding a new draft.'),
+ });
});
export const deleteDraft = ({ commit, getters }, draft) =>
@@ -39,7 +43,11 @@ export const deleteDraft = ({ commit, getters }, draft) =>
.then(() => {
commit(types.DELETE_DRAFT, draft.id);
})
- .catch(() => flash(__('An error occurred while deleting the comment')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while deleting the comment'),
+ }),
+ );
export const fetchDrafts = ({ commit, getters, state, dispatch }) =>
service
@@ -53,7 +61,11 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) =>
}
});
})
- .catch(() => flash(__('An error occurred while fetching pending comments')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while fetching pending comments'),
+ }),
+ );
export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => {
commit(types.REQUEST_PUBLISH_DRAFT, draftId);
@@ -111,7 +123,11 @@ export const updateDraft = (
.then((res) => res.data)
.then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data))
.then(callback)
- .catch(() => flash(__('An error occurred while updating the comment')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while updating the comment'),
+ }),
+ );
};
export const scrollToDraft = ({ dispatch, rootGetters }, draft) => {
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 5fecadf2794..293fe9f4133 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { once, countBy } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { __, sprintf } from '~/locale';
@@ -78,7 +78,9 @@ function importMermaidModule() {
mermaidModule = initMermaid(mermaid);
})
.catch((err) => {
- flash(sprintf(__("Can't load mermaid module: %{err}"), { err }));
+ createFlash({
+ message: sprintf(__("Can't load mermaid module: %{err}"), { err }),
+ });
// eslint-disable-next-line no-console
console.error(err);
});
@@ -205,7 +207,9 @@ function renderMermaids($els) {
});
})
.catch((err) => {
- flash(sprintf(__('Encountered an error while rendering: %{err}'), { err }));
+ createFlash({
+ message: sprintf(__('Encountered an error while rendering: %{err}'), { err }),
+ });
// eslint-disable-next-line no-console
console.error(err);
});
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 5405819cfe0..a1911585f80 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -1,7 +1,7 @@
/* eslint-disable func-names */
import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -79,7 +79,11 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
};
success(data);
})
- .catch(() => flash(__('An error occurred while fetching markdown preview')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while fetching markdown preview'),
+ }),
+ );
};
MarkdownPreview.prototype.hideReferencedUsers = function ($form) {
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
index c9152db509a..af8e8a4cd3d 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -1,9 +1,11 @@
+import createFlash from '~/flash';
import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
function onError() {
- const flash = new Flash(__('Balsamiq file could not be loaded.'));
+ const flash = createFlash({
+ message: __('Balsamiq file could not be loaded.'),
+ });
return flash;
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 60729c11002..1a74675100b 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -27,6 +27,11 @@ export default {
default: false,
required: false,
},
+ richViewer: {
+ type: String,
+ default: '',
+ required: false,
+ },
loading: {
type: Boolean,
default: true,
@@ -71,6 +76,7 @@ export default {
v-else
ref="contentViewer"
:content="content"
+ :rich-viewer="richViewer"
:is-raw-content="isRawContent"
:file-name="blob.name"
:type="activeViewer.fileType"
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
index 73ccc3289b9..0e670bbd80a 100644
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_content.vue
@@ -1,6 +1,6 @@
<script>
import { debounce } from 'lodash';
-import { initEditorLite } from '~/blob/utils';
+import { initSourceEditor } from '~/blob/utils';
import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import eventHub from './eventhub';
@@ -36,7 +36,7 @@ export default {
},
},
mounted() {
- this.editor = initEditorLite({
+ this.editor = initSourceEditor({
el: this.$refs.editor,
blobPath: this.fileName,
blobContent: this.value,
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 99fe3938046..cb441a7e491 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -29,7 +29,7 @@ export default {
<slot name="filepath-prepend"></slot>
<template v-if="blob.path">
- <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
+ <file-icon :file-name="blob.path" :size="16" aria-hidden="true" css-classes="mr-2" />
<strong
class="file-title-name mr-1 js-blob-header-filepath"
data-qa-selector="file_title_content"
diff --git a/app/assets/javascripts/blob/csv/csv_viewer.vue b/app/assets/javascripts/blob/csv/csv_viewer.vue
new file mode 100644
index 00000000000..050f2785d9a
--- /dev/null
+++ b/app/assets/javascripts/blob/csv/csv_viewer.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import Papa from 'papaparse';
+
+export default {
+ components: {
+ GlTable,
+ GlAlert,
+ GlLoadingIcon,
+ },
+ props: {
+ csv: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ items: [],
+ errorMessage: null,
+ loading: true,
+ };
+ },
+ mounted() {
+ const parsed = Papa.parse(this.csv, { skipEmptyLines: true });
+ this.items = parsed.data;
+
+ if (parsed.errors.length) {
+ this.errorMessage = parsed.errors.map((e) => e.message).join('. ');
+ }
+
+ this.loading = false;
+ },
+};
+</script>
+
+<template>
+ <div class="container-fluid md gl-mt-3 gl-mb-3">
+ <div v-if="loading" class="gl-text-center loading">
+ <gl-loading-icon class="gl-mt-5" size="lg" />
+ </div>
+ <div v-else>
+ <gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
+ <gl-table
+ :empty-text="__('No CSV data to display.')"
+ :items="items"
+ :fields="$options.fields"
+ show-empty
+ thead-class="gl-display-none"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/csv/index.js b/app/assets/javascripts/blob/csv/index.js
new file mode 100644
index 00000000000..4cf6c169c68
--- /dev/null
+++ b/app/assets/javascripts/blob/csv/index.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import CsvViewer from './csv_viewer.vue';
+
+export default () => {
+ const el = document.getElementById('js-csv-viewer');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(CsvViewer, {
+ props: {
+ csv: el.dataset.data,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/blob/csv_viewer.js b/app/assets/javascripts/blob/csv_viewer.js
new file mode 100644
index 00000000000..64d3ba0b390
--- /dev/null
+++ b/app/assets/javascripts/blob/csv_viewer.js
@@ -0,0 +1,3 @@
+import renderCSV from './csv';
+
+export default renderCSV;
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 59ab84bf208..136457c115d 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -2,11 +2,10 @@ import $ from 'jquery';
import Api from '~/api';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
-import { deprecatedCreateFlash as Flash } from '../flash';
-
import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
@@ -146,7 +145,7 @@ export default class FileTemplateMediator {
text: __('Undo'),
onClick: (e, toastObj) => {
self.restoreFromCache();
- toastObj.goAway(0);
+ toastObj.hide();
},
},
});
@@ -155,7 +154,11 @@ export default class FileTemplateMediator {
initPopover(suggestCommitChanges);
}
})
- .catch((err) => new Flash(`An error occurred while fetching the template: ${err}`));
+ .catch((err) =>
+ createFlash({
+ message: __(`An error occurred while fetching the template: ${err}`),
+ }),
+ );
}
displayMatchedTemplateSelector() {
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index e6dc463f764..cb251274b18 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -1,5 +1,5 @@
import { SwaggerUIBundle } from 'swagger-ui-dist';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
export default () => {
@@ -13,7 +13,9 @@ export default () => {
});
})
.catch((error) => {
- flash(__('Something went wrong while initializing the OpenAPI viewer'));
+ createFlash({
+ message: __('Something went wrong while initializing the OpenAPI viewer'),
+ });
throw error;
});
};
diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js
index 8043c0bbc07..bbc061dd36e 100644
--- a/app/assets/javascripts/blob/utils.js
+++ b/app/assets/javascripts/blob/utils.js
@@ -1,6 +1,6 @@
-import Editor from '~/editor/editor_lite';
+import Editor from '~/editor/source_editor';
-export function initEditorLite({ el, ...args }) {
+export function initSourceEditor({ el, ...args }) {
const editor = new Editor({
scrollbar: {
alwaysConsumeMouseWheel: false,
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 22c6b31143f..4d133659daa 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,14 +1,16 @@
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import {
REPO_BLOB_LOAD_VIEWER_START,
REPO_BLOB_LOAD_VIEWER_FINISH,
REPO_BLOB_LOAD_VIEWER,
+ REPO_BLOB_SWITCH_TO_VIEWER_START,
+ REPO_BLOB_SWITCH_VIEWER,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { fixTitle } from '~/tooltips';
-import { deprecatedCreateFlash as Flash } from '../../flash';
import axios from '../../lib/utils/axios_utils';
import { handleLocationHash } from '../../lib/utils/common_utils';
import eventHub from '../../notes/event_hub';
@@ -21,6 +23,8 @@ const loadRichBlobViewer = (type) => {
return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer');
case 'openapi':
return import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer');
+ case 'csv':
+ return import(/* webpackChunkName: 'csv_viewer' */ '../csv_viewer');
case 'pdf':
return import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer');
case 'sketch':
@@ -38,13 +42,18 @@ export const handleBlobRichViewer = (viewer, type) => {
loadRichBlobViewer(type)
.then((module) => module?.default(viewer))
.catch((error) => {
- Flash(__('Error loading file viewer.'));
+ createFlash({
+ message: __('Error loading file viewer.'),
+ });
throw error;
});
};
export default class BlobViewer {
constructor() {
+ performanceMarkAndMeasure({
+ mark: REPO_BLOB_LOAD_VIEWER_START,
+ });
const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
const type = viewer?.dataset?.richType;
BlobViewer.initAuxiliaryViewer();
@@ -137,7 +146,7 @@ export default class BlobViewer {
switchToViewer(name) {
performanceMarkAndMeasure({
- mark: REPO_BLOB_LOAD_VIEWER_START,
+ mark: REPO_BLOB_SWITCH_TO_VIEWER_START,
});
const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
@@ -167,11 +176,15 @@ export default class BlobViewer {
BlobViewer.loadViewer(newViewer)
.then((viewer) => {
$(viewer).renderGFM();
+ window.requestIdleCallback(() => {
+ this.$fileHolder.trigger('highlight:line');
+ handleLocationHash();
- this.$fileHolder.trigger('highlight:line');
- handleLocationHash();
+ viewer.setAttribute('data-loaded', 'true');
+ this.toggleCopyButtonState();
+ eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
+ });
- this.toggleCopyButtonState();
performanceMarkAndMeasure({
mark: REPO_BLOB_LOAD_VIEWER_FINISH,
measures: [
@@ -179,10 +192,18 @@ export default class BlobViewer {
name: REPO_BLOB_LOAD_VIEWER,
start: REPO_BLOB_LOAD_VIEWER_START,
},
+ {
+ name: REPO_BLOB_SWITCH_VIEWER,
+ start: REPO_BLOB_SWITCH_TO_VIEWER_START,
+ },
],
});
})
- .catch(() => new Flash(__('Error loading viewer')));
+ .catch(() =>
+ createFlash({
+ message: __('Error loading viewer'),
+ }),
+ );
}
static loadViewer(viewerParam) {
@@ -197,9 +218,10 @@ export default class BlobViewer {
return axios.get(url).then(({ data }) => {
viewer.innerHTML = data.html;
- viewer.setAttribute('data-loaded', 'true');
- eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
+ window.requestIdleCallback(() => {
+ viewer.removeAttribute('data-loading');
+ });
return viewer;
});
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 7c8d0d5ded0..7bfda46d71c 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
-import EditorLite from '~/editor/editor_lite';
-import { FileTemplateExtension } from '~/editor/extensions/editor_file_template_ext';
+import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
+import SourceEditor from '~/editor/source_editor';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
@@ -16,7 +16,7 @@ export default class EditBlob {
this.configureMonacoEditor();
if (this.options.isMarkdown) {
- import('~/editor/extensions/editor_markdown_ext')
+ import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(new MarkdownExtension());
addEditorMarkdownListeners(this.editor);
@@ -40,7 +40,7 @@ export default class EditBlob {
const fileContentEl = document.getElementById('file-content');
const form = document.querySelector('.js-edit-blob-form');
- const rootEditor = new EditorLite();
+ const rootEditor = new SourceEditor();
this.editor = rootEditor.createInstance({
el: editorEl,
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index e14a770411e..46f97e09385 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -54,6 +54,7 @@ export function formatListIssues(listIssues) {
const listIssue = {
...i,
id,
+ fullId: i.id,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
};
@@ -106,8 +107,8 @@ export function formatIssueInput(issueInput, boardConfig) {
const { labels, assigneeId, milestoneId } = boardConfig;
return {
- milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null,
...issueInput,
+ milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null,
labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])],
assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])],
};
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
index 0f92e714752..b81edb4dfe6 100644
--- a/app/assets/javascripts/boards/components/board_blocked_icon.vue
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
-import { IssueType } from '~/graphql_shared/constants';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
@@ -13,7 +13,7 @@ export default {
},
},
graphQLIdType: {
- [issuableTypes.issue]: IssueType,
+ [issuableTypes.issue]: TYPE_ISSUE,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
@@ -163,7 +163,7 @@ export default {
><span data-testid="popover-title">{{ blockedLabel }}</span></template
>
<template v-if="loading">
- <gl-loading-icon />
+ <gl-loading-icon size="sm" />
<p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p>
</template>
<template v-else>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 2f4e9044b9e..05b64ddc773 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -1,5 +1,12 @@
<script>
-import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlLabel,
+ GlTooltip,
+ GlTooltipDirective,
+ GlIcon,
+ GlLoadingIcon,
+ GlSprintf,
+} from '@gitlab/ui';
import { sortBy } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
@@ -16,6 +23,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue';
export default {
components: {
+ GlTooltip,
GlLabel,
GlLoadingIcon,
GlIcon,
@@ -25,6 +33,7 @@ export default {
IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
BoardBlockedIcon,
+ GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -55,7 +64,7 @@ export default {
};
},
computed: {
- ...mapState(['isShowingLabels', 'issuableType']),
+ ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']),
...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
@@ -99,6 +108,12 @@ export default {
}
return false;
},
+ shouldRenderEpicCountables() {
+ return this.isEpicBoard && this.item.hasIssues;
+ },
+ shouldRenderEpicProgress() {
+ return this.totalWeight > 0;
+ },
showLabelFooter() {
return this.isShowingLabels && this.item.labels.find(this.showLabel);
},
@@ -115,6 +130,20 @@ export default {
}
return __('Blocked issue');
},
+ totalEpicsCount() {
+ return this.item.descendantCounts.openedEpics + this.item.descendantCounts.closedEpics;
+ },
+ totalIssuesCount() {
+ return this.item.descendantCounts.openedIssues + this.item.descendantCounts.closedIssues;
+ },
+ totalWeight() {
+ return (
+ this.item.descendantWeightSum.openedIssues + this.item.descendantWeightSum.closedIssues
+ );
+ },
+ totalProgress() {
+ return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100);
+ },
},
methods: {
...mapActions(['performSearch', 'setError']),
@@ -227,17 +256,93 @@ export default {
{{ itemId }}
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
- <issue-due-date
- v-if="item.dueDate"
- :date="item.dueDate"
- :closed="item.closed || Boolean(item.closedAt)"
- />
- <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
- <issue-card-weight
- v-if="validIssueWeight(item)"
- :weight="item.weight"
- @click="filterByWeight(item.weight)"
- />
+ <span v-if="shouldRenderEpicCountables" data-testid="epic-countables">
+ <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip">
+ <p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0">
+ {{ __('Epics') }} &#8226;
+ <span class="gl-font-weight-normal">
+ <gl-sprintf :message="__('%{openedEpics} open, %{closedEpics} closed')">
+ <template #openedEpics>{{ item.descendantCounts.openedEpics }}</template>
+ <template #closedEpics>{{ item.descendantCounts.closedEpics }}</template>
+ </gl-sprintf>
+ </span>
+ </p>
+ <p class="gl-font-weight-bold gl-m-0">
+ {{ __('Issues') }} &#8226;
+ <span class="gl-font-weight-normal">
+ <gl-sprintf :message="__('%{openedIssues} open, %{closedIssues} closed')">
+ <template #openedIssues>{{ item.descendantCounts.openedIssues }}</template>
+ <template #closedIssues>{{ item.descendantCounts.closedIssues }}</template>
+ </gl-sprintf>
+ </span>
+ </p>
+ <p class="gl-font-weight-bold gl-m-0">
+ {{ __('Total weight') }} &#8226;
+ <span class="gl-font-weight-normal" data-testid="epic-countables-total-weight">
+ {{ totalWeight }}
+ </span>
+ </p>
+ </gl-tooltip>
+
+ <gl-tooltip
+ v-if="shouldRenderEpicProgress"
+ :target="() => $refs.progressBadge"
+ data-testid="epic-progress-tooltip"
+ >
+ <p class="gl-font-weight-bold gl-m-0">
+ {{ __('Progress') }} &#8226;
+ <span class="gl-font-weight-normal" data-testid="epic-progress-tooltip-content">
+ <gl-sprintf
+ :message="__('%{completedWeight} of %{totalWeight} weight completed')"
+ >
+ <template #completedWeight>{{
+ item.descendantWeightSum.closedIssues
+ }}</template>
+ <template #totalWeight>{{ totalWeight }}</template>
+ </gl-sprintf>
+ </span>
+ </p>
+ </gl-tooltip>
+
+ <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0">
+ <span v-if="allowSubEpics" class="gl-mr-3">
+ <gl-icon name="epic" />
+ {{ totalEpicsCount }}
+ </span>
+ <span class="gl-mr-3" data-testid="epic-countables-counts-issues">
+ <gl-icon name="issues" />
+ {{ totalIssuesCount }}
+ </span>
+ <span class="gl-mr-3" data-testid="epic-countables-weight-issues">
+ <gl-icon name="weight" />
+ {{ totalWeight }}
+ </span>
+ </span>
+
+ <span
+ v-if="shouldRenderEpicProgress"
+ ref="progressBadge"
+ class="issue-count-badge board-card-info gl-pl-0"
+ >
+ <span class="gl-mr-3" data-testid="epic-progress">
+ <gl-icon name="progress" />
+ {{ totalProgress }}%
+ </span>
+ </span>
+ </span>
+ <span v-if="!isEpicBoard">
+ <issue-due-date
+ v-if="item.dueDate"
+ :date="item.dueDate"
+ :closed="item.closed || Boolean(item.closedAt)"
+ />
+ <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" />
+ <issue-card-weight
+ v-if="validIssueWeight(item)"
+ :weight="item.weight"
+ @click="filterByWeight(item.weight)"
+ />
+ </span>
</span>
</div>
<div class="board-card-assignee gl-display-flex">
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index cc7262f3a39..69abf886ad7 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -41,7 +41,7 @@ export default {
watch: {
filterParams: {
handler() {
- if (this.list.id) {
+ if (this.list.id && !this.list.collapsed) {
this.fetchItemsForList({ listId: this.list.id });
}
},
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index b770ac06e89..53b071aaed1 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -12,10 +12,8 @@ import BoardColumnDeprecated from './board_column_deprecated.vue';
export default {
components: {
BoardAddNewColumn,
- BoardColumn:
- gon.features?.graphqlBoardLists || gon.features?.epicBoards
- ? BoardColumn
- : BoardColumnDeprecated,
+ BoardColumn,
+ BoardColumnDeprecated,
BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
EpicBoardContentSidebar: () =>
import('ee_component/boards/components/epic_board_content_sidebar.vue'),
@@ -38,11 +36,14 @@ export default {
computed: {
...mapState(['boardLists', 'error', 'addColumnForm']),
...mapGetters(['isSwimlanesOn', 'isEpicBoard']),
+ useNewBoardColumnComponent() {
+ return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard;
+ },
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
boardListsToUse() {
- return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard
+ return this.useNewBoardColumnComponent
? sortBy([...Object.values(this.boardLists)], 'position')
: this.lists;
},
@@ -65,6 +66,9 @@ export default {
return this.canDragColumns ? options : {};
},
+ boardColumnComponent() {
+ return this.useNewBoardColumnComponent ? BoardColumn : BoardColumnDeprecated;
+ },
},
methods: {
...mapActions(['moveList', 'unsetError']),
@@ -102,7 +106,8 @@ export default {
class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
@end="handleDragOnEnd"
>
- <board-column
+ <component
+ :is="boardColumnComponent"
v-for="(list, index) in boardListsToUse"
:key="index"
ref="board"
@@ -125,14 +130,9 @@ export default {
<board-content-sidebar
v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
- class="boards-sidebar"
data-testid="issue-boards-sidebar"
/>
- <epic-board-content-sidebar
- v-else-if="isEpicBoard"
- class="boards-sidebar"
- data-testid="epic-boards-sidebar"
- />
+ <epic-board-content-sidebar v-else-if="isEpicBoard" data-testid="epic-boards-sidebar" />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 16a8a9d253f..e014b82d362 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -1,20 +1,20 @@
<script>
import { GlDrawer } from '@gitlab/ui';
+import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
-import { contentTop } from '~/lib/utils/common_utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
+import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
- headerHeight: `${contentTop()}px`,
components: {
GlDrawer,
BoardSidebarTitle,
@@ -25,8 +25,10 @@ export default {
BoardSidebarLabelsSelect,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
- BoardSidebarWeightInput: () =>
- import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
+ SidebarTodoWidget,
+ MountingPortal,
+ SidebarWeightWidget: () =>
+ import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
IterationSidebarDropdownWidget: () =>
import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'),
},
@@ -45,6 +47,7 @@ export default {
default: false,
},
},
+ inheritAttrs: false,
computed: {
...mapGetters([
'isSidebarOpen',
@@ -64,7 +67,12 @@ export default {
},
},
methods: {
- ...mapActions(['toggleBoardItem', 'setAssignees', 'setActiveItemConfidential']),
+ ...mapActions([
+ 'toggleBoardItem',
+ 'setAssignees',
+ 'setActiveItemConfidential',
+ 'setActiveItemWeight',
+ ]),
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
@@ -73,87 +81,105 @@ export default {
</script>
<template>
- <gl-drawer
- v-if="showSidebar"
- :open="isSidebarOpen"
- :header-height="$options.headerHeight"
- @close="handleClose"
- >
- <template #header>{{ __('Issue details') }}</template>
- <template #default>
- <board-sidebar-title />
- <sidebar-assignees-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :initial-assignees="activeBoardItem.assignees"
- :allow-multiple-assignees="multipleAssigneesFeatureAvailable"
- @assignees-updated="setAssignees"
- />
- <sidebar-dropdown-widget
- v-if="epicFeatureAvailable"
- :iid="activeBoardItem.iid"
- issuable-attribute="epic"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- data-testid="sidebar-epic"
- />
- <div>
+ <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
+ <gl-drawer
+ v-if="showSidebar"
+ v-bind="$attrs"
+ :open="isSidebarOpen"
+ class="boards-sidebar gl-absolute"
+ @close="handleClose"
+ >
+ <template #title>
+ <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2>
+ </template>
+ <template #header>
+ <sidebar-todo-widget
+ class="gl-mt-3"
+ :issuable-id="activeBoardItem.fullId"
+ :issuable-iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ />
+ </template>
+ <template #default>
+ <board-sidebar-title />
+ <sidebar-assignees-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :initial-assignees="activeBoardItem.assignees"
+ :allow-multiple-assignees="multipleAssigneesFeatureAvailable"
+ @assignees-updated="setAssignees"
+ />
<sidebar-dropdown-widget
+ v-if="epicFeatureAvailable"
:iid="activeBoardItem.iid"
- issuable-attribute="milestone"
+ issuable-attribute="epic"
:workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="projectPathForActiveIssue"
+ :attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
- data-testid="sidebar-milestones"
+ data-testid="sidebar-epic"
/>
- <template v-if="!glFeatures.iterationCadences">
+ <div>
<sidebar-dropdown-widget
- v-if="iterationFeatureAvailable"
:iid="activeBoardItem.iid"
- issuable-attribute="iteration"
+ issuable-attribute="milestone"
:workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
+ :attr-workspace-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- data-qa-selector="iteration_container"
+ data-testid="sidebar-milestones"
/>
- </template>
- <template v-else>
- <iteration-sidebar-dropdown-widget
- v-if="iterationFeatureAvailable"
- :iid="activeBoardItem.iid"
- :workspace-path="projectPathForActiveIssue"
- :attr-workspace-path="groupPathForActiveIssue"
- :issuable-type="issuableType"
- class="gl-mt-5"
- data-testid="iteration-edit"
- data-qa-selector="iteration_container"
- />
- </template>
- </div>
- <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
- <sidebar-date-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :issuable-type="issuableType"
- data-testid="sidebar-due-date"
- />
- <board-sidebar-labels-select class="labels" />
- <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" />
- <sidebar-confidentiality-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :issuable-type="issuableType"
- @confidentialityUpdated="setActiveItemConfidential($event)"
- />
- <sidebar-subscriptions-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :issuable-type="issuableType"
- data-testid="sidebar-notifications"
- />
- </template>
- </gl-drawer>
+ <template v-if="!glFeatures.iterationCadences">
+ <sidebar-dropdown-widget
+ v-if="iterationFeatureAvailable"
+ :iid="activeBoardItem.iid"
+ issuable-attribute="iteration"
+ :workspace-path="projectPathForActiveIssue"
+ :attr-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ data-testid="iteration-edit"
+ />
+ </template>
+ <template v-else>
+ <iteration-sidebar-dropdown-widget
+ v-if="iterationFeatureAvailable"
+ :iid="activeBoardItem.iid"
+ :workspace-path="projectPathForActiveIssue"
+ :attr-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ data-testid="iteration-edit"
+ />
+ </template>
+ </div>
+ <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
+ <sidebar-date-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ data-testid="sidebar-due-date"
+ />
+ <board-sidebar-labels-select class="labels" />
+ <sidebar-weight-widget
+ v-if="weightFeatureAvailable"
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ @weightUpdated="setActiveItemWeight($event)"
+ />
+ <sidebar-confidentiality-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ @confidentialityUpdated="setActiveItemConfidential($event)"
+ />
+ <sidebar-subscriptions-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ data-testid="sidebar-notifications"
+ />
+ </template>
+ </gl-drawer>
+ </mounting-portal>
</template>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 13388f02f1f..cfd6b21fa66 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -27,7 +27,7 @@ export default {
},
computed: {
urlParams() {
- const { authorUsername, labelName, search } = this.filterParams;
+ const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
let notParams = {};
if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
@@ -35,6 +35,7 @@ export default {
{
'not[label_name][]': this.filterParams.not.labelName,
'not[author_username]': this.filterParams.not.authorUsername,
+ 'not[assignee_username]': this.filterParams.not.assigneeUsername,
},
undefined,
);
@@ -44,6 +45,7 @@ export default {
...notParams,
author_username: authorUsername,
'label_name[]': labelName,
+ assignee_username: assigneeUsername,
search,
};
},
@@ -62,7 +64,7 @@ export default {
this.performSearch();
},
getFilteredSearchValue() {
- const { authorUsername, labelName, search } = this.filterParams;
+ const { authorUsername, labelName, assigneeUsername, search } = this.filterParams;
const filteredSearchValue = [];
if (authorUsername) {
@@ -72,6 +74,13 @@ export default {
});
}
+ if (assigneeUsername) {
+ filteredSearchValue.push({
+ type: 'assignee_username',
+ value: { data: assigneeUsername, operator: '=' },
+ });
+ }
+
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
@@ -88,6 +97,13 @@ export default {
});
}
+ if (this.filterParams['not[assigneeUsername]']) {
+ filteredSearchValue.push({
+ type: 'assignee_username',
+ value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' },
+ });
+ }
+
if (this.filterParams['not[labelName]']) {
filteredSearchValue.push(
...this.filterParams['not[labelName]'].map((label) => ({
@@ -121,6 +137,9 @@ export default {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
+ case 'assignee_username':
+ filterParams.assigneeUsername = filter.value.data;
+ break;
case 'label_name':
labels.push(filter.value.data);
break;
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index aa75a0d68f5..386ed6bd0a1 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -2,9 +2,9 @@
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import ListLabel from '~/boards/models/label';
+import { TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { getParameterByName } from '~/lib/utils/common_utils';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { fullLabelId, fullBoardId } from '../boards_util';
import { formType } from '../constants';
@@ -188,21 +188,19 @@ export default {
};
},
issueBoardScopeMutationVariables() {
- /* eslint-disable @gitlab/require-i18n-strings */
return {
weight: this.board.weight,
assigneeId: this.board.assignee?.id
- ? convertToGraphQLId('User', this.board.assignee.id)
+ ? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
: null,
milestoneId:
this.board.milestone?.id || this.board.milestone?.id === 0
- ? convertToGraphQLId('Milestone', this.board.milestone.id)
+ ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id)
: null,
iterationId: this.board.iteration_id
- ? convertToGraphQLId('Iteration', this.board.iteration_id)
+ ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id)
: null,
};
- /* eslint-enable @gitlab/require-i18n-strings */
},
boardScopeMutationVariables() {
return {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 81740b5cd17..8dca6be853f 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking';
+import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
@@ -21,6 +22,7 @@ export default {
components: {
BoardCard,
BoardNewIssue,
+ BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'),
GlLoadingIcon,
GlIntersectionObserver,
},
@@ -49,6 +51,7 @@ export default {
scrollOffset: 250,
showCount: false,
showIssueForm: false,
+ showEpicForm: false,
};
},
computed: {
@@ -64,6 +67,9 @@ export default {
issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
+ toggleFormEventPrefix() {
+ return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue;
+ },
boardItemsSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
@@ -76,6 +82,12 @@ export default {
loadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
},
+ epicCreateFormVisible() {
+ return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
+ },
+ issueCreateFormVisible() {
+ return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
+ },
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
@@ -116,9 +128,10 @@ export default {
'list.id': {
handler(id, oldVal) {
if (id) {
- eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
- eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm);
+
+ eventHub.$off(`${this.toggleFormEventPrefix}${oldVal}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop);
}
},
@@ -126,7 +139,7 @@ export default {
},
},
beforeDestroy() {
- eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
methods: {
@@ -147,7 +160,11 @@ export default {
this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
- this.showIssueForm = !this.showIssueForm;
+ if (this.isEpicBoard) {
+ this.showEpicForm = !this.showEpicForm;
+ } else {
+ this.showIssueForm = !this.showIssueForm;
+ }
},
onReachingListBottom() {
if (!this.loadingMore && this.hasNextPage) {
@@ -225,9 +242,10 @@ export default {
:aria-label="$options.i18n.loading"
data-testid="board_list_loading"
>
- <gl-loading-icon />
+ <gl-loading-icon size="sm" />
</div>
- <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
+ <board-new-issue v-if="issueCreateFormVisible" :list="list" />
+ <board-new-epic v-if="epicCreateFormVisible" :list="list" />
<component
:is="treeRootWrapper"
v-show="!loading"
@@ -255,6 +273,7 @@ export default {
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
<gl-loading-icon
v-if="loadingMore"
+ size="sm"
:label="$options.i18n.loadingMoreboardItems"
data-testid="count-loading-icon"
/>
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index 9b3e7e1547d..fabaf7a85f5 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -429,7 +429,7 @@ export default {
data-qa-selector="board_list_cards_area"
>
<div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
- <gl-loading-icon />
+ <gl-loading-icon size="sm" />
</div>
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
<ul
@@ -450,7 +450,7 @@ export default {
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
- <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
+ <gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index bf8396f52a6..8d5f0f7eb89 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
import AccessorUtilities from '../../lib/utils/accessor';
-import { inactiveId, LIST, ListType } from '../constants';
+import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import ItemCount from './item_count.vue';
export default {
i18n: {
newIssue: __('New issue'),
+ newEpic: s__('Boards|New epic'),
listSettings: __('List settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
@@ -72,7 +73,7 @@ export default {
},
computed: {
...mapState(['activeId']),
- ...mapGetters(['isEpicBoard']),
+ ...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
@@ -102,7 +103,7 @@ export default {
},
showListHeaderActions() {
if (this.isLoggedIn) {
- return this.isNewIssueShown || this.isSettingsShown;
+ return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown;
}
return false;
},
@@ -124,6 +125,9 @@ export default {
isNewIssueShown() {
return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
},
+ isNewEpicShown() {
+ return this.isEpicBoard && this.listType !== ListType.closed;
+ },
isSettingsShown() {
return (
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
@@ -165,7 +169,17 @@ export default {
},
showNewIssueForm() {
- eventHub.$emit(`toggle-issue-form-${this.list.id}`);
+ if (this.isSwimlanesOn) {
+ eventHub.$emit('open-unassigned-lane');
+ this.$nextTick(() => {
+ eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
+ });
+ } else {
+ eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
+ }
+ },
+ showNewEpicForm() {
+ eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
},
toggleExpanded() {
const collapsed = !this.list.collapsed;
@@ -342,7 +356,7 @@ export default {
<!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
+ class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500"
data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
@@ -380,6 +394,17 @@ export default {
/>
<gl-button
+ v-if="isNewEpicShown"
+ v-show="!list.collapsed"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.newEpic"
+ :title="$options.i18n.newEpic"
+ class="no-drag"
+ icon="plus"
+ @click="showNewEpicForm"
+ />
+
+ <gl-button
v-if="isSettingsShown"
ref="settingsBtn"
v-gl-tooltip.hover
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index a63b49f9508..caeecb25227 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -4,13 +4,13 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { __ } from '~/locale';
+import { toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
export default {
name: 'BoardNewIssue',
i18n: {
- submit: __('Create issue'),
cancel: __('Cancel'),
},
components: {
@@ -32,7 +32,15 @@ export default {
},
computed: {
...mapState(['selectedProject']),
- ...mapGetters(['isGroupBoard']),
+ ...mapGetters(['isGroupBoard', 'isEpicBoard']),
+ /**
+ * We've extended this component in EE where
+ * submitButtonTitle returns a different string
+ * hence this is kept as a computed prop.
+ */
+ submitButtonTitle() {
+ return __('Create issue');
+ },
disabled() {
if (this.isGroupBoard) {
return this.title === '' || !this.selectedProject.name;
@@ -50,9 +58,7 @@ export default {
},
methods: {
...mapActions(['addListNewIssue']),
- submit(e) {
- e.preventDefault();
-
+ submit() {
const { title } = this;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
@@ -76,7 +82,7 @@ export default {
},
reset() {
this.title = '';
- eventHub.$emit(`toggle-issue-form-${this.list.id}`);
+ eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
},
},
};
@@ -85,7 +91,7 @@ export default {
<template>
<div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded">
- <form ref="submitForm" @submit="submit">
+ <form ref="submitForm" @submit.prevent="submit">
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
<input
:id="inputFieldId"
@@ -96,7 +102,7 @@ export default {
name="issue_title"
autocomplete="off"
/>
- <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
+ <project-select v-if="isGroupBoard && !isEpicBoard" :group-id="groupId" :list="list" />
<div class="clearfix gl-mt-3">
<gl-button
ref="submitButton"
@@ -106,7 +112,7 @@ export default {
category="primary"
type="submit"
>
- {{ $options.i18n.submit }}
+ {{ submitButtonTitle }}
</gl-button>
<gl-button
ref="cancelButton"
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 75975c77df5..c089a6a39af 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui';
+import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
@@ -9,14 +10,13 @@ import eventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options.
export default {
- headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px',
listSettingsText: __('List settings'),
components: {
GlButton,
GlDrawer,
GlLabel,
+ MountingPortal,
BoardSettingsSidebarWipLimit: () =>
import('ee_component/boards/components/board_settings_wip_limit.vue'),
BoardSettingsListTypes: () =>
@@ -24,6 +24,7 @@ export default {
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
inject: ['canAdminList'],
+ inheritAttrs: false,
data() {
return {
ListType,
@@ -86,43 +87,45 @@ export default {
</script>
<template>
- <gl-drawer
- v-if="showSidebar"
- class="js-board-settings-sidebar"
- :open="isSidebarOpen"
- :header-height="$options.headerHeight"
- @close="unsetActiveId"
- >
- <template #header>{{ $options.listSettingsText }}</template>
- <template v-if="isSidebarOpen">
- <div v-if="boardListType === ListType.label">
- <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
- <gl-label
- :title="activeListLabel.title"
- :background-color="activeListLabel.color"
- :scoped="showScopedLabels(activeListLabel)"
- />
- </div>
+ <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append>
+ <gl-drawer
+ v-if="showSidebar"
+ v-bind="$attrs"
+ class="js-board-settings-sidebar gl-absolute"
+ :open="isSidebarOpen"
+ @close="unsetActiveId"
+ >
+ <template #title>{{ $options.listSettingsText }}</template>
+ <template v-if="isSidebarOpen">
+ <div v-if="boardListType === ListType.label">
+ <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
+ <gl-label
+ :title="activeListLabel.title"
+ :background-color="activeListLabel.color"
+ :scoped="showScopedLabels(activeListLabel)"
+ />
+ </div>
- <board-settings-list-types
- v-else
- :active-list="activeList"
- :board-list-type="boardListType"
- />
- <board-settings-sidebar-wip-limit
- v-if="isWipLimitsOn"
- :max-issue-count="activeList.maxIssueCount"
- />
- <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4">
- <gl-button
- variant="danger"
- category="secondary"
- icon="remove"
- data-testid="remove-list"
- @click.stop="deleteBoard"
- >{{ __('Remove list') }}
- </gl-button>
- </div>
- </template>
- </gl-drawer>
+ <board-settings-list-types
+ v-else
+ :active-list="activeList"
+ :board-list-type="boardListType"
+ />
+ <board-settings-sidebar-wip-limit
+ v-if="isWipLimitsOn"
+ :max-issue-count="activeList.maxIssueCount"
+ />
+ <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4">
+ <gl-button
+ variant="danger"
+ category="secondary"
+ icon="remove"
+ data-testid="remove-list"
+ @click.stop="deleteBoard"
+ >{{ __('Remove list') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-drawer>
+ </mounting-portal>
</template>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 55bc91cbcff..21a34182369 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -105,7 +105,7 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
- setAssignees(assignees) {
+ setAssignees({ assignees }) {
boardsStore.detail.issue.setAssignees(assignees);
},
showScopedLabels(label) {
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 5124467136e..98027917221 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -327,7 +327,7 @@ export default {
:class="scrollFadeClass"
></div>
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<div v-if="canAdminBoard">
<gl-dropdown-divider />
diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
index 85c7b27336b..c1536dff2c6 100644
--- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
+++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
@@ -316,7 +316,7 @@ export default {
:class="scrollFadeClass"
></div>
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<div v-if="canAdminBoard">
<gl-dropdown-divider />
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
new file mode 100644
index 00000000000..d8dac17d326
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -0,0 +1,102 @@
+<script>
+import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
+import issueBoardFilters from '~/boards/issue_board_filters';
+import { TYPE_USER } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { __ } from '~/locale';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
+
+export default {
+ i18n: {
+ search: __('Search'),
+ label: __('Label'),
+ author: __('Author'),
+ assignee: __('Assignee'),
+ is: __('is'),
+ isNot: __('is not'),
+ },
+ components: { BoardFilteredSearch },
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ boardType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tokens() {
+ const { label, is, isNot, author, assignee } = this.$options.i18n;
+ const { fetchAuthors, fetchLabels } = issueBoardFilters(
+ this.$apollo,
+ this.fullPath,
+ this.boardType,
+ );
+
+ return [
+ {
+ icon: 'labels',
+ title: label,
+ type: 'label_name',
+ operators: [
+ { value: '=', description: is },
+ { value: '!=', description: isNot },
+ ],
+ token: LabelToken,
+ unique: false,
+ symbol: '~',
+ fetchLabels,
+ },
+ {
+ icon: 'pencil',
+ title: author,
+ type: 'author_username',
+ operators: [
+ { value: '=', description: is },
+ { value: '!=', description: isNot },
+ ],
+ symbol: '@',
+ token: AuthorToken,
+ unique: true,
+ fetchAuthors,
+ preloadedAuthors: this.preloadedAuthors(),
+ },
+ {
+ icon: 'user',
+ title: assignee,
+ type: 'assignee_username',
+ operators: [
+ { value: '=', description: is },
+ { value: '!=', description: isNot },
+ ],
+ token: AuthorToken,
+ unique: true,
+ fetchAuthors,
+ preloadedAuthors: this.preloadedAuthors(),
+ },
+ ];
+ },
+ },
+ methods: {
+ preloadedAuthors() {
+ return gon?.current_user_id
+ ? [
+ {
+ id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
+ name: gon.current_user_fullname,
+ username: gon.current_username,
+ avatarUrl: gon.current_user_avatar_url,
+ },
+ ]
+ : [];
+ },
+ },
+};
+</script>
+
+<template>
+ <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" />
+</template>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 2fd16f06455..6eb1dbfb46a 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -3,7 +3,7 @@
import $ from 'jquery';
import store from '~/boards/stores';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -53,7 +53,9 @@ export default function initNewListDropdown() {
data(term, callback) {
const reqFailed = () => {
$dropdownToggle.data('bs.dropdown').hide();
- flash(__('Error fetching labels.'));
+ createFlash({
+ message: __('Error fetching labels.'),
+ });
};
if (store.getters.shouldUseGraphQL) {
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 77b6af77652..1412411c275 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -126,7 +126,7 @@ export default {
v-show="groupProjectsFlags.isLoading"
data-testid="dropdown-text-loading-icon"
>
- <gl-loading-icon class="gl-mx-auto" />
+ <gl-loading-icon class="gl-mx-auto" size="sm" />
</gl-dropdown-text>
<gl-dropdown-text
v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading"
diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue
index afe161d9c54..fc95ba0461d 100644
--- a/app/assets/javascripts/boards/components/project_select_deprecated.vue
+++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue
@@ -136,7 +136,7 @@ export default {
{{ project.namespacedName }}
</gl-dropdown-item>
<gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
- <gl-loading-icon class="gl-mx-auto" />
+ <gl-loading-icon class="gl-mx-auto" size="sm" />
</gl-dropdown-text>
<gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index 352a25ef6d9..84802650dad 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -93,7 +93,7 @@ export default {
<slot name="title">
<span data-testid="title">{{ title }}</span>
</slot>
- <gl-loading-icon v-if="loading" inline class="gl-ml-2" />
+ <gl-loading-icon v-if="loading" size="sm" inline class="gl-ml-2" />
</span>
<gl-button
v-if="canUpdate"
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 80a8fc99895..21ef70582a4 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -45,6 +45,11 @@ export const formType = {
edit: 'edit',
};
+export const toggleFormEventPrefix = {
+ epic: 'toggle-epic-form-',
+ issue: 'toggle-issue-form-',
+};
+
export const inactiveId = 0;
export const ISSUABLE = 'issuable';
diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
index 3c5f4b3e3bd..70eb1dfbf7e 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
@@ -1,6 +1,7 @@
mutation issueSetLabels($input: UpdateIssueInput!) {
updateIssue(input: $input) {
issue {
+ id
labels {
nodes {
id
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index fb347ce852d..de7c8a3bd6b 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,4 +1,5 @@
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import PortalVue from 'portal-vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapActions, mapGetters } from 'vuex';
@@ -24,6 +25,7 @@ import '~/boards/filters/due_date_filters';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
+import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import toggleFocusMode from '~/boards/toggle_focus';
@@ -41,6 +43,7 @@ import boardConfigToggle from './config_toggle';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
+Vue.use(PortalVue);
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
@@ -76,6 +79,10 @@ export default () => {
issueBoardsApp.$destroy(true);
}
+ if (gon?.features?.issueBoardsFilteredSearch) {
+ initBoardsFilteredSearch(apolloProvider);
+ }
+
if (!gon?.features?.graphqlBoardLists) {
boardsStore.create();
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
@@ -182,9 +189,14 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
- this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
-
- this.filterManager.setup();
+ if (!gon?.features?.issueBoardsFilteredSearch) {
+ this.filterManager = new FilteredSearchBoards(
+ boardsStore.filter,
+ true,
+ boardsStore.cantEdit,
+ );
+ this.filterManager.setup();
+ }
this.performSearch();
@@ -304,9 +316,11 @@ export default () => {
// eslint-disable-next-line no-new, @gitlab/no-runtime-template-compiler
new Vue({
el: document.getElementById('js-add-list'),
- data: {
- filters: boardsStore.state.filters,
- ...getMilestoneTitle($boardApp),
+ data() {
+ return {
+ filters: boardsStore.state.filters,
+ ...getMilestoneTitle($boardApp),
+ };
},
mounted() {
initNewListDropdown();
diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js
new file mode 100644
index 00000000000..699d7e12de4
--- /dev/null
+++ b/app/assets/javascripts/boards/issue_board_filters.js
@@ -0,0 +1,47 @@
+import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql';
+import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql';
+import { BoardType } from './constants';
+import boardLabels from './graphql/board_labels.query.graphql';
+
+export default function issueBoardFilters(apollo, fullPath, boardType) {
+ const isGroupBoard = boardType === BoardType.group;
+ const isProjectBoard = boardType === BoardType.project;
+ const transformLabels = ({ data }) => {
+ return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || [];
+ };
+
+ const boardAssigneesQuery = () => {
+ return isGroupBoard ? groupBoardMembers : projectBoardMembers;
+ };
+
+ const fetchAuthors = (authorsSearchTerm) => {
+ return apollo
+ .query({
+ query: boardAssigneesQuery(),
+ variables: {
+ fullPath,
+ search: authorsSearchTerm,
+ },
+ })
+ .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user));
+ };
+
+ const fetchLabels = (labelSearchTerm) => {
+ return apollo
+ .query({
+ query: boardLabels,
+ variables: {
+ fullPath,
+ searchTerm: labelSearchTerm,
+ isGroup: isGroupBoard,
+ isProject: isProjectBoard,
+ },
+ })
+ .then(transformLabels);
+ };
+
+ return {
+ fetchLabels,
+ fetchAuthors,
+ };
+}
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index a95d749d71c..1bb0ee5b7e3 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,6 +1,6 @@
/* global DocumentTouch */
-import sortableConfig from 'ee_else_ce/sortable/sortable_config';
+import sortableConfig from '~/sortable/sortable_config';
export function sortableStart() {
document.body.classList.add('is-dragging');
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 6c6e2522d92..ab24532d87f 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import boardsStore from '../stores/boards_store';
import ListAssignee from './assignee';
@@ -127,7 +127,11 @@ class List {
moveBeforeId,
moveAfterId,
})
- .catch(() => flash(__('Something went wrong while moving issues.')));
+ .catch(() =>
+ createFlash({
+ message: __('Something went wrong while moving issues.'),
+ }),
+ );
}
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
@@ -145,7 +149,11 @@ class List {
moveBeforeId,
moveAfterId,
})
- .catch(() => flash(__('Something went wrong while moving issues.')));
+ .catch(() =>
+ createFlash({
+ message: __('Something went wrong while moving issues.'),
+ }),
+ );
}
findIssue(id) {
diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
new file mode 100644
index 00000000000..7732091ef34
--- /dev/null
+++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue';
+import store from '~/boards/stores';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { queryToObject } from '~/lib/utils/url_utility';
+
+export default (apolloProvider) => {
+ const el = document.getElementById('js-issue-board-filtered-search');
+ const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
+
+ const initialFilterParams = {
+ ...convertObjectPropsToCamelCase(rawFilterParams, {}),
+ };
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ provide: {
+ initialFilterParams,
+ },
+ store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094
+ apolloProvider,
+ render: (createElement) =>
+ createElement(IssueBoardFilteredSearch, {
+ props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index d4893f9eca7..0f1b72146c9 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -18,7 +18,9 @@ import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
-import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+// eslint-disable-next-line import/no-deprecated
+import { urlParamsToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import {
formatBoardLists,
@@ -74,6 +76,7 @@ export default {
performSearch({ dispatch }) {
dispatch(
'setFilters',
+ // eslint-disable-next-line import/no-deprecated
convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)),
);
@@ -170,8 +173,9 @@ export default {
addList: ({ commit, dispatch, getters }, list) => {
commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
+
dispatch('fetchItemsForList', {
- listId: getters.getListByTitle(ListTypeTitles.backlog).id,
+ listId: getters.getListByTitle(ListTypeTitles.backlog)?.id,
});
},
@@ -237,7 +241,7 @@ export default {
},
updateList: (
- { commit, state: { issuableType } },
+ { commit, state: { issuableType, boardItemsByListId = {} }, dispatch },
{ listId, position, collapsed, backupList },
) => {
gqlClient
@@ -252,6 +256,12 @@ export default {
.then(({ data }) => {
if (data?.updateBoardList?.errors.length) {
commit(types.UPDATE_LIST_FAILURE, backupList);
+ return;
+ }
+
+ // Only fetch when board items havent been fetched on a collapsed list
+ if (!boardItemsByListId[listId]) {
+ dispatch('fetchItemsForList', { listId });
}
})
.catch(() => {
@@ -285,7 +295,7 @@ export default {
commit(types.REMOVE_LIST_FAILURE, listsBackup);
} else {
dispatch('fetchItemsForList', {
- listId: getters.getListByTitle(ListTypeTitles.backlog).id,
+ listId: getters.getListByTitle(ListTypeTitles.backlog)?.id,
});
}
},
@@ -296,6 +306,8 @@ export default {
},
fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
+ if (!listId) return null;
+
if (!fetchNext) {
commit(types.RESET_ITEMS_FOR_LIST, listId);
}
@@ -469,11 +481,11 @@ export default {
}
},
- setAssignees: ({ commit, getters }, assigneeUsernames) => {
+ setAssignees: ({ commit }, { id, assignees }) => {
commit('UPDATE_BOARD_ITEM_BY_ID', {
- itemId: getters.activeBoardItem.id,
+ itemId: id,
prop: 'assignees',
- value: assigneeUsernames,
+ value: assignees,
});
},
@@ -701,4 +713,7 @@ export default {
unsetError: ({ commit }) => {
commit(types.SET_ERROR, undefined);
},
+
+ // EE action needs CE empty equivalent
+ setActiveItemWeight: () => {},
};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 092f81ad279..49c40c7776a 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -7,13 +7,9 @@ import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createDefaultClient from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
-import {
- urlParamsToObject,
- getUrlParamsArray,
- parseBoolean,
- convertObjectPropsToCamelCase,
-} from '~/lib/utils/common_utils';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+// eslint-disable-next-line import/no-deprecated
+import { mergeUrlParams, urlParamsToObject, getUrlParamsArray } from '~/lib/utils/url_utility';
import { ListType, flashAnimationDuration } from '../constants';
import eventHub from '../eventhub';
import ListAssignee from '../models/assignee';
@@ -601,6 +597,7 @@ const boardsStore = {
getListIssues(list, emptyIssues = true) {
const data = {
+ // eslint-disable-next-line import/no-deprecated
...urlParamsToObject(this.filter.path),
page: list.page,
};
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index b61ecc5ccb6..140c9ef7ac4 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -16,7 +16,7 @@ export default {
},
activeBoardItem: (state) => {
- return state.boardItems[state.activeId] || {};
+ return state.boardItems[state.activeId] || { iid: '', id: '', fullId: '' };
},
groupPathForActiveIssue: (_, getters) => {
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 6cd0a62657e..a32a100fa11 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -35,13 +35,23 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, boardId, fullBoardId, fullPath, boardConfig, issuableType } = data;
+ const {
+ allowSubEpics,
+ boardConfig,
+ boardId,
+ boardType,
+ disabled,
+ fullBoardId,
+ fullPath,
+ issuableType,
+ } = data;
+ state.allowSubEpics = allowSubEpics;
+ state.boardConfig = boardConfig;
state.boardId = boardId;
- state.fullBoardId = fullBoardId;
- state.fullPath = fullPath;
state.boardType = boardType;
state.disabled = disabled;
- state.boardConfig = boardConfig;
+ state.fullBoardId = fullBoardId;
+ state.fullPath = fullPath;
state.issuableType = issuableType;
},
diff --git a/app/assets/javascripts/branches/components/delete_branch_button.vue b/app/assets/javascripts/branches/components/delete_branch_button.vue
index 5a5f49e25e7..6a6d4d48c52 100644
--- a/app/assets/javascripts/branches/components/delete_branch_button.vue
+++ b/app/assets/javascripts/branches/components/delete_branch_button.vue
@@ -47,12 +47,6 @@ export default {
},
},
computed: {
- variant() {
- if (this.disabled) {
- return 'default';
- }
- return 'danger';
- },
title() {
if (this.isProtectedBranch && this.disabled) {
return s__('Branches|Only a project maintainer or owner can delete a protected branch');
@@ -83,7 +77,7 @@ export default {
class="js-delete-branch-button"
data-qa-selector="delete_branch_button"
:disabled="disabled"
- :variant="variant"
+ variant="default"
:title="title"
:aria-label="title"
@click="openModal"
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index b88c056b00f..31cf9a18077 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import createFlash from '../flash';
+import createFlash from '~/flash';
import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
import DivergenceGraph from './components/divergence_graph.vue';
diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
index c9eac44eb28..fdab188f6be 100644
--- a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
+++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js
@@ -1,4 +1,33 @@
-const supportedMethods = ['patch', 'post', 'put'];
+const SUPPORTED_METHODS = ['patch', 'post', 'put'];
+
+function needsCaptchaResponse(err) {
+ return (
+ SUPPORTED_METHODS.includes(err?.config?.method) && err?.response?.data?.needs_captcha_response
+ );
+}
+
+const showCaptchaModalAndResubmit = async (axios, data, errConfig) => {
+ // NOTE: We asynchronously import and unbox the module. Since this is included globally, we don't
+ // do a regular import because that would increase the size of the webpack bundle.
+ const { waitForCaptchaToBeSolved } = await import('~/captcha/wait_for_captcha_to_be_solved');
+
+ // show the CAPTCHA modal and wait for it to be solved or closed
+ const captchaResponse = await waitForCaptchaToBeSolved(data.captcha_site_key);
+
+ // resubmit the original request with the captcha_response and spam_log_id in the headers
+ const originalData = JSON.parse(errConfig.data);
+ const originalHeaders = errConfig.headers;
+ return axios({
+ method: errConfig.method,
+ url: errConfig.url,
+ headers: {
+ ...originalHeaders,
+ 'X-GitLab-Captcha-Response': captchaResponse,
+ 'X-GitLab-Spam-Log-Id': data.spam_log_id,
+ },
+ data: originalData,
+ });
+};
export function registerCaptchaModalInterceptor(axios) {
return axios.interceptors.response.use(
@@ -6,29 +35,8 @@ export function registerCaptchaModalInterceptor(axios) {
return response;
},
(err) => {
- if (
- supportedMethods.includes(err?.config?.method) &&
- err?.response?.data?.needs_captcha_response
- ) {
- const { data } = err.response;
- const captchaSiteKey = data.captcha_site_key;
- const spamLogId = data.spam_log_id;
- // eslint-disable-next-line promise/no-promise-in-callback
- return import('~/captcha/wait_for_captcha_to_be_solved')
- .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
- .then((captchaResponse) => {
- const errConfig = err.config;
- const originalData = JSON.parse(errConfig.data);
- return axios({
- method: errConfig.method,
- url: errConfig.url,
- data: {
- ...originalData,
- captcha_response: captchaResponse,
- spam_log_id: spamLogId,
- },
- });
- });
+ if (needsCaptchaResponse(err)) {
+ return showCaptchaModalAndResubmit(axios, err.response.data, err.config);
}
return Promise.reject(err);
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index ced07dea7be..bc8a1f05ef5 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -2,7 +2,7 @@
import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql';
-import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
components: {
@@ -12,7 +12,7 @@ export default {
GlLink,
GlAlert,
CiLintResults,
- EditorLite,
+ SourceEditor,
},
props: {
endpoint: {
@@ -93,7 +93,7 @@ export default {
<div class="js-file-title file-title clearfix">
{{ __('Contents of .gitlab-ci.yml') }}
</div>
- <editor-lite v-model="content" file-name="*.yml" />
+ <source-editor v-model="content" file-name="*.yml" />
</div>
</div>
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 762b37a8216..c2c035963f4 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,18 +1,14 @@
import { GlToast } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import Vue from 'vue';
+import createFlash from '~/flash';
import AccessorUtilities from '~/lib/utils/accessor';
import initProjectSelectDropdown from '~/project_select';
-import initServerlessSurveyBanner from '~/serverless/survey_banner';
-import { deprecatedCreateFlash as Flash } from '../flash';
import Poll from '../lib/utils/poll';
-import { s__, sprintf } from '../locale';
+import { s__ } from '../locale';
import PersistentUserCallout from '../persistent_user_callout';
import initSettingsPanels from '../settings_panels';
-import Applications from './components/applications.vue';
import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue';
-import { APPLICATION_STATUS, CROSSPLANE, KNATIVE } from './constants';
-import eventHub from './event_hub';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
@@ -20,46 +16,20 @@ const Environments = () => import('ee_component/clusters/components/environments
Vue.use(GlToast);
-/**
- * Cluster page has 2 separate parts:
- * Toggle button and applications section
- *
- * - Polling status while creating or scheduled
- * - Update status area with the response result
- */
-
export default class Clusters {
constructor() {
const {
statusPath,
- installHelmPath,
- installIngressPath,
- installCertManagerPath,
- installRunnerPath,
- installJupyterPath,
- installKnativePath,
- updateKnativePath,
- installElasticStackPath,
- installCrossplanePath,
- installPrometheusPath,
- managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
providerType,
- preInstalledKnative,
- clusterType,
clusterStatus,
clusterStatusReason,
helpPath,
- helmHelpPath,
- ingressHelpPath,
- ingressDnsHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
- cloudRunHelpPath,
clusterId,
- ciliumHelpPath,
} = document.querySelector('.js-edit-cluster-form').dataset;
this.clusterId = clusterId;
@@ -69,38 +39,19 @@ export default class Clusters {
this.store = new ClustersStore();
this.store.setHelpPaths({
helpPath,
- helmHelpPath,
- ingressHelpPath,
- ingressDnsHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
- cloudRunHelpPath,
- ciliumHelpPath,
});
- this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
this.store.updateProviderType(providerType);
- this.store.updatePreInstalledKnative(preInstalledKnative);
this.store.updateRbac(hasRbac);
this.service = new ClustersService({
endpoint: statusPath,
- installHelmEndpoint: installHelmPath,
- installIngressEndpoint: installIngressPath,
- installCertManagerEndpoint: installCertManagerPath,
- installCrossplaneEndpoint: installCrossplanePath,
- installRunnerEndpoint: installRunnerPath,
- installPrometheusEndpoint: installPrometheusPath,
- installJupyterEndpoint: installJupyterPath,
- installKnativeEndpoint: installKnativePath,
- updateKnativeEndpoint: updateKnativePath,
- installElasticStackEndpoint: installElasticStackPath,
clusterEnvironmentsEndpoint: clusterEnvironmentsPath,
});
- this.installApplication = this.installApplication.bind(this);
-
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
@@ -109,14 +60,12 @@ export default class Clusters {
'.js-cluster-authentication-failure',
);
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
- this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
this.tokenField = document.querySelector('.js-cluster-token');
initProjectSelectDropdown();
Clusters.initDismissableCallout();
initSettingsPanels();
- this.initApplications(clusterType);
this.initEnvironments();
if (clusterEnvironmentsPath && this.environments) {
@@ -143,38 +92,6 @@ export default class Clusters {
this.initRemoveClusterActions();
}
- initApplications(type) {
- const { store } = this;
- const el = document.querySelector('#js-cluster-applications');
-
- this.applications = new Vue({
- el,
- data() {
- return {
- state: store.state,
- };
- },
- render(createElement) {
- return createElement(Applications, {
- props: {
- type,
- applications: this.state.applications,
- helpPath: this.state.helpPath,
- helmHelpPath: this.state.helmHelpPath,
- ingressHelpPath: this.state.ingressHelpPath,
- managePrometheusPath: this.state.managePrometheusPath,
- ingressDnsHelpPath: this.state.ingressDnsHelpPath,
- cloudRunHelpPath: this.state.cloudRunHelpPath,
- providerType: this.state.providerType,
- preInstalledKnative: this.state.preInstalledKnative,
- rbac: this.state.rbac,
- ciliumHelpPath: this.state.ciliumHelpPath,
- },
- });
- },
- });
- }
-
initEnvironments() {
const { store } = this;
const el = document.querySelector('#js-cluster-environments');
@@ -242,30 +159,11 @@ export default class Clusters {
}
addListeners() {
- eventHub.$on('installApplication', this.installApplication);
- eventHub.$on('updateApplication', (data) => this.updateApplication(data));
- eventHub.$on('saveKnativeDomain', (data) => this.saveKnativeDomain(data));
- eventHub.$on('setKnativeDomain', (data) => this.setKnativeDomain(data));
- eventHub.$on('uninstallApplication', (data) => this.uninstallApplication(data));
- eventHub.$on('setCrossplaneProviderStack', (data) => this.setCrossplaneProviderStack(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
}
- removeListeners() {
- eventHub.$off('installApplication', this.installApplication);
- eventHub.$off('updateApplication', this.updateApplication);
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('saveKnativeDomain');
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('setKnativeDomain');
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('setCrossplaneProviderStack');
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('uninstallApplication');
- }
-
initPolling(method, successCallback, errorCallback) {
this.poll = new Poll({
resource: this.service,
@@ -298,21 +196,17 @@ export default class Clusters {
}
static handleError() {
- Flash(s__('ClusterIntegration|Something went wrong on our end.'));
+ createFlash({
+ message: s__('ClusterIntegration|Something went wrong on our end.'),
+ });
}
handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status;
- const prevApplicationMap = { ...this.store.state.applications };
this.store.updateStateFromServer(data.data);
- this.checkForNewInstalls(prevApplicationMap, this.store.state.applications);
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
-
- if (this.store.state.applications[KNATIVE]?.status === APPLICATION_STATUS.INSTALLED) {
- initServerlessSurveyBanner();
- }
}
hideAll() {
@@ -323,27 +217,6 @@ export default class Clusters {
this.authenticationFailureContainer.classList.add('hidden');
}
- checkForNewInstalls(prevApplicationMap, newApplicationMap) {
- const appTitles = Object.keys(newApplicationMap)
- .filter(
- (appId) =>
- newApplicationMap[appId].status === APPLICATION_STATUS.INSTALLED &&
- prevApplicationMap[appId].status !== APPLICATION_STATUS.INSTALLED &&
- prevApplicationMap[appId].status !== null,
- )
- .map((appId) => newApplicationMap[appId].title);
-
- if (appTitles.length > 0) {
- const text = sprintf(
- s__('ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster'),
- {
- appList: appTitles.join(', '),
- },
- );
- Flash(text, 'notice', this.successApplicationContainer);
- }
- }
-
setBannerDismissedState(status, isDismissed) {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
window.localStorage.setItem(this.clusterBannerDismissedKey, `${status}_${isDismissed}`);
@@ -416,91 +289,9 @@ export default class Clusters {
}
}
- installApplication({ id: appId, params }) {
- return Clusters.validateInstallation(appId, params)
- .then(() => {
- this.store.updateAppProperty(appId, 'requestReason', null);
- this.store.updateAppProperty(appId, 'statusReason', null);
- this.store.installApplication(appId);
-
- // eslint-disable-next-line promise/no-nesting
- this.service.installApplication(appId, params).catch(() => {
- this.store.notifyInstallFailure(appId);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin installing failed'),
- );
- });
- })
- .catch((error) => this.store.updateAppProperty(appId, 'validationError', error));
- }
-
- static validateInstallation(appId, params) {
- return new Promise((resolve, reject) => {
- if (appId === CROSSPLANE && !params.stack) {
- reject(s__('ClusterIntegration|Select a stack to install Crossplane.'));
- return;
- }
-
- if (appId === KNATIVE && !params.hostname && !params.pages_domain_id) {
- reject(s__('ClusterIntegration|You must specify a domain before you can install Knative.'));
- return;
- }
-
- resolve();
- });
- }
-
- uninstallApplication({ id: appId }) {
- this.store.updateAppProperty(appId, 'requestReason', null);
- this.store.updateAppProperty(appId, 'statusReason', null);
-
- this.store.uninstallApplication(appId);
-
- return this.service.uninstallApplication(appId).catch(() => {
- this.store.notifyUninstallFailure(appId);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin uninstalling failed'),
- );
- });
- }
-
- updateApplication({ id: appId, params }) {
- this.store.updateApplication(appId);
- this.service.installApplication(appId, params).catch(() => {
- this.store.notifyUpdateFailure(appId);
- });
- }
-
- saveKnativeDomain(data) {
- const appId = data.id;
- this.store.updateApplication(appId);
- this.service.updateApplication(appId, data.params).catch(() => {
- this.store.notifyUpdateFailure(appId);
- });
- }
-
- setKnativeDomain({ id: appId, domain, domainId }) {
- this.store.updateAppProperty(appId, 'isEditingDomain', true);
- this.store.updateAppProperty(appId, 'hostname', domain);
- this.store.updateAppProperty(appId, 'pagesDomain', domainId ? { id: domainId, domain } : null);
- this.store.updateAppProperty(appId, 'validationError', null);
- }
-
- setCrossplaneProviderStack(data) {
- const appId = data.id;
- this.store.updateAppProperty(appId, 'stack', data.stack.code);
- this.store.updateAppProperty(appId, 'validationError', null);
- }
-
destroy() {
this.destroyed = true;
- this.removeListeners();
-
if (this.poll) {
this.poll.stop();
}
@@ -508,7 +299,5 @@ export default class Clusters {
if (this.environments) {
this.environments.$destroy();
}
-
- this.applications.$destroy();
}
}
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
deleted file mode 100644
index a53b63ea592..00000000000
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ /dev/null
@@ -1,478 +0,0 @@
-<script>
-import { GlLink, GlModalDirective, GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import identicon from '../../vue_shared/components/identicon.vue';
-import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants';
-import eventHub from '../event_hub';
-import UninstallApplicationButton from './uninstall_application_button.vue';
-import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
-import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue';
-
-export default {
- components: {
- GlButton,
- identicon,
- GlLink,
- GlAlert,
- GlSprintf,
- UninstallApplicationButton,
- UninstallApplicationConfirmationModal,
- UpdateApplicationConfirmationModal,
- },
- directives: {
- GlModalDirective,
- },
- props: {
- id: {
- type: String,
- required: true,
- },
- title: {
- type: String,
- required: true,
- },
- titleLink: {
- type: String,
- required: false,
- default: '',
- },
- manageLink: {
- type: String,
- required: false,
- default: '',
- },
- logoUrl: {
- type: String,
- required: false,
- default: '',
- },
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- installable: {
- type: Boolean,
- required: false,
- default: true,
- },
- uninstallable: {
- type: Boolean,
- required: false,
- default: false,
- },
- status: {
- type: String,
- required: false,
- default: '',
- },
- statusReason: {
- type: String,
- required: false,
- default: '',
- },
- requestReason: {
- type: String,
- required: false,
- default: '',
- },
- installed: {
- type: Boolean,
- required: false,
- default: false,
- },
- installFailed: {
- type: Boolean,
- required: false,
- default: false,
- },
- version: {
- type: String,
- required: false,
- default: '',
- },
- chartRepo: {
- type: String,
- required: false,
- default: '',
- },
- updateAvailable: {
- type: Boolean,
- required: false,
- },
- updateable: {
- type: Boolean,
- default: true,
- required: false,
- },
- updateSuccessful: {
- type: Boolean,
- required: false,
- default: false,
- },
- updateFailed: {
- type: Boolean,
- required: false,
- default: false,
- },
- uninstallFailed: {
- type: Boolean,
- required: false,
- default: false,
- },
- uninstallSuccessful: {
- type: Boolean,
- required: false,
- default: false,
- },
- installApplicationRequestParams: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
- computed: {
- isUnknownStatus() {
- return !this.isKnownStatus && this.status !== null;
- },
- isKnownStatus() {
- return Object.values(APPLICATION_STATUS).includes(this.status);
- },
- isInstalling() {
- return this.status === APPLICATION_STATUS.INSTALLING;
- },
- isExternallyInstalled() {
- return this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED;
- },
- canInstall() {
- return (
- this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
- this.status === APPLICATION_STATUS.INSTALLABLE ||
- this.status === APPLICATION_STATUS.UNINSTALLED ||
- this.isUnknownStatus
- );
- },
- hasLogo() {
- return Boolean(this.logoUrl);
- },
- identiconId() {
- // generate a deterministic integer id for the identicon background
- return this.id.charCodeAt(0);
- },
- rowJsClass() {
- return `js-cluster-application-row-${this.id}`;
- },
- displayUninstallButton() {
- return this.installed && this.uninstallable;
- },
- displayInstallButton() {
- return !this.installed || !this.uninstallable;
- },
- installButtonLoading() {
- return !this.status || this.isInstalling;
- },
- installButtonDisabled() {
- // Applications installed through the management project can
- // only be installed through the CI pipeline. Installation should
- // be disable in all states.
- if (!this.installable) return true;
-
- // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
- // we already made a request to install and are just waiting for the real-time
- // to sync up.
- if (this.isInstalling) return true;
-
- if (!this.isKnownStatus) return false;
-
- return (
- this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR
- );
- },
- installButtonLabel() {
- let label;
- if (this.canInstall) {
- label = __('Install');
- } else if (this.isInstalling) {
- label = __('Installing');
- } else if (this.installed) {
- label = __('Installed');
- } else if (this.isExternallyInstalled) {
- label = __('Externally installed');
- }
-
- return label;
- },
- buttonGridCellClass() {
- return this.showManageButton || this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED
- ? 'section-25'
- : 'section-15';
- },
- showManageButton() {
- return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED;
- },
- manageButtonLabel() {
- return __('Manage');
- },
- hasError() {
- return this.installFailed || this.uninstallFailed;
- },
- generalErrorDescription() {
- let errorDescription;
-
- if (this.installFailed) {
- errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}');
- } else if (this.uninstallFailed) {
- errorDescription = s__(
- 'ClusterIntegration|Something went wrong while uninstalling %{title}',
- );
- }
-
- return sprintf(errorDescription, { title: this.title });
- },
- updateFailureDescription() {
- return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
- },
- updateSuccessDescription() {
- return sprintf(s__('ClusterIntegration|%{title} updated successfully.'), {
- title: this.title,
- });
- },
- updateButtonLabel() {
- let label;
- if (this.updateAvailable && !this.updateFailed && !this.isUpdating) {
- label = __('Update');
- } else if (this.isUpdating) {
- label = __('Updating');
- } else if (this.updateFailed) {
- label = __('Retry update');
- }
-
- return label;
- },
- updatingNeedsConfirmation() {
- if (this.version) {
- const majorVersion = parseInt(this.version.split('.')[0], 10);
-
- if (!Number.isNaN(majorVersion)) {
- return this.id === ELASTIC_STACK && majorVersion < 3;
- }
- }
-
- return false;
- },
- isUpdating() {
- // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
- return this.status === APPLICATION_STATUS.UPDATING;
- },
- shouldShowUpdateDetails() {
- // This method only returns true when;
- // Update was successful OR Update failed
- // AND new update is unavailable AND version information is present.
- return (this.updateSuccessful || this.updateFailed) && !this.updateAvailable && this.version;
- },
- uninstallSuccessDescription() {
- return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), {
- title: this.title,
- });
- },
- updateModalId() {
- return `update-${this.id}`;
- },
- uninstallModalId() {
- return `uninstall-${this.id}`;
- },
- },
- watch: {
- updateSuccessful(updateSuccessful) {
- if (updateSuccessful) {
- this.$toast.show(this.updateSuccessDescription);
- }
- },
- uninstallSuccessful(uninstallSuccessful) {
- if (uninstallSuccessful) {
- this.$toast.show(this.uninstallSuccessDescription);
- }
- },
- },
- methods: {
- installClicked() {
- if (this.disabled || this.installButtonDisabled) return;
-
- eventHub.$emit('installApplication', {
- id: this.id,
- params: this.installApplicationRequestParams,
- });
- },
- updateConfirmed() {
- if (this.isUpdating) return;
-
- eventHub.$emit('updateApplication', {
- id: this.id,
- params: this.installApplicationRequestParams,
- });
- },
- uninstallConfirmed() {
- eventHub.$emit('uninstallApplication', {
- id: this.id,
- });
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="[
- rowJsClass,
- installed && 'cluster-application-installed',
- disabled && 'cluster-application-disabled',
- ]"
- class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span"
- :data-qa-selector="id"
- >
- <div class="gl-responsive-table-row-layout" role="row">
- <div class="table-section gl-mr-3 section-align-top" role="gridcell">
- <img
- v-if="hasLogo"
- :src="logoUrl"
- :alt="`${title} logo`"
- class="cluster-application-logo avatar s40"
- />
- <identicon v-else :entity-id="identiconId" :entity-name="title" size-class="s40" />
- </div>
- <div class="table-section cluster-application-description section-wrap" role="gridcell">
- <strong>
- <a
- v-if="titleLink"
- :href="titleLink"
- target="_blank"
- rel="noopener noreferrer"
- class="js-cluster-application-title"
- >{{ title }}</a
- >
- <span v-else class="js-cluster-application-title">{{ title }}</span>
- </strong>
- <slot name="installed-via"></slot>
- <div>
- <slot name="description"></slot>
- </div>
- <div v-if="hasError" class="cluster-application-error text-danger gl-mt-3">
- <p class="js-cluster-application-general-error-message gl-mb-0">
- {{ generalErrorDescription }}
- </p>
- <ul v-if="statusReason || requestReason">
- <li v-if="statusReason" class="js-cluster-application-status-error-message">
- {{ statusReason }}
- </li>
- <li v-if="requestReason" class="js-cluster-application-request-error-message">
- {{ requestReason }}
- </li>
- </ul>
- </div>
-
- <div v-if="updateable">
- <div
- v-if="shouldShowUpdateDetails"
- class="form-text text-muted label p-0 js-cluster-application-update-details"
- >
- <template v-if="updateFailed">{{ __('Update failed') }}</template>
- <template v-else-if="isUpdating">{{ __('Updating') }}</template>
- <template v-else>
- <gl-sprintf :message="__('Updated to %{linkStart}chart v%{linkEnd}')">
- <template #link="{ content }">
- <gl-link
- :href="chartRepo"
- target="_blank"
- class="js-cluster-application-update-version"
- >{{ content }}{{ version }}</gl-link
- >
- </template>
- </gl-sprintf>
- </template>
- </div>
-
- <gl-alert
- v-if="updateFailed && !isUpdating"
- variant="danger"
- :dismissible="false"
- class="gl-mt-3 gl-mb-0 js-cluster-application-update-details"
- >
- {{ updateFailureDescription }}
- </gl-alert>
- <template v-if="updateAvailable || updateFailed || isUpdating">
- <template v-if="updatingNeedsConfirmation">
- <gl-button
- v-gl-modal-directive="updateModalId"
- class="js-cluster-application-update-button mt-2"
- variant="info"
- category="primary"
- :loading="isUpdating"
- :disabled="isUpdating"
- data-qa-selector="update_button_with_confirmation"
- :data-qa-application="id"
- >
- {{ updateButtonLabel }}
- </gl-button>
- <update-application-confirmation-modal
- :application="id"
- :application-title="title"
- @confirm="updateConfirmed()"
- />
- </template>
-
- <gl-button
- v-else
- class="js-cluster-application-update-button mt-2"
- variant="info"
- category="primary"
- :loading="isUpdating"
- :disabled="isUpdating"
- data-qa-selector="update_button"
- :data-qa-application="id"
- @click="updateConfirmed"
- >
- {{ updateButtonLabel }}
- </gl-button>
- </template>
- </div>
- </div>
- <div
- :class="[buttonGridCellClass, 'table-section', 'table-button-footer', 'section-align-top']"
- role="gridcell"
- >
- <div v-if="showManageButton" class="btn-group table-action-buttons">
- <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{
- manageButtonLabel
- }}</a>
- </div>
- <div class="btn-group table-action-buttons">
- <gl-button
- v-if="displayInstallButton"
- :loading="installButtonLoading"
- :disabled="disabled || installButtonDisabled"
- class="js-cluster-application-install-button"
- variant="default"
- data-qa-selector="install_button"
- :data-qa-application="id"
- @click="installClicked"
- >
- {{ installButtonLabel }}
- </gl-button>
- <uninstall-application-button
- v-if="displayUninstallButton"
- v-gl-modal-directive="uninstallModalId"
- :status="status"
- data-qa-selector="uninstall_button"
- :data-qa-application="id"
- class="js-cluster-application-uninstall-button"
- />
- <uninstall-application-confirmation-modal
- :application="id"
- :application-title="title"
- @confirm="uninstallConfirmed()"
- />
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
deleted file mode 100644
index ddee1711975..00000000000
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ /dev/null
@@ -1,662 +0,0 @@
-<script>
-import { GlLoadingIcon, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
-import certManagerLogo from 'images/cluster_app_logos/cert_manager.png';
-import crossplaneLogo from 'images/cluster_app_logos/crossplane.png';
-import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png';
-import gitlabLogo from 'images/cluster_app_logos/gitlab.png';
-import helmLogo from 'images/cluster_app_logos/helm.png';
-import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png';
-import knativeLogo from 'images/cluster_app_logos/knative.png';
-import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png';
-import prometheusLogo from 'images/cluster_app_logos/prometheus.png';
-import eventHub from '~/clusters/event_hub';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
-import applicationRow from './application_row.vue';
-import CrossplaneProviderStack from './crossplane_provider_stack.vue';
-import KnativeDomainEditor from './knative_domain_editor.vue';
-
-export default {
- components: {
- applicationRow,
- clipboardButton,
- GlLoadingIcon,
- GlSprintf,
- GlLink,
- KnativeDomainEditor,
- CrossplaneProviderStack,
- GlAlert,
- },
- props: {
- type: {
- type: String,
- required: false,
- default: CLUSTER_TYPE.PROJECT,
- },
- applications: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- helpPath: {
- type: String,
- required: false,
- default: '',
- },
- helmHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- ingressHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- ingressDnsHelpPath: {
- type: String,
- required: false,
- default: '',
- },
-
- cloudRunHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- managePrometheusPath: {
- type: String,
- required: false,
- default: '',
- },
- providerType: {
- type: String,
- required: false,
- default: '',
- },
- preInstalledKnative: {
- type: Boolean,
- required: false,
- default: false,
- },
- rbac: {
- type: Boolean,
- required: false,
- default: false,
- },
- ciliumHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- ingressId() {
- return INGRESS;
- },
- ingressInstalled() {
- return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED;
- },
- ingressExternalEndpoint() {
- return this.applications.ingress.externalIp || this.applications.ingress.externalHostname;
- },
- certManagerInstalled() {
- return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
- },
- jupyterInstalled() {
- return this.applications.jupyter.status === APPLICATION_STATUS.INSTALLED;
- },
- jupyterHostname() {
- return this.applications.jupyter.hostname;
- },
- knative() {
- return this.applications.knative;
- },
- crossplane() {
- return this.applications.crossplane;
- },
- cloudRun() {
- return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative;
- },
- ingress() {
- return this.applications.ingress;
- },
- },
- methods: {
- saveKnativeDomain() {
- eventHub.$emit('saveKnativeDomain', {
- id: 'knative',
- params: {
- hostname: this.applications.knative.hostname,
- pages_domain_id: this.applications.knative.pagesDomain?.id,
- },
- });
- },
- setKnativeDomain({ domainId, domain }) {
- eventHub.$emit('setKnativeDomain', {
- id: 'knative',
- domainId,
- domain,
- });
- },
- setCrossplaneProviderStack(stack) {
- eventHub.$emit('setCrossplaneProviderStack', {
- id: 'crossplane',
- stack,
- });
- },
- },
- logos: {
- gitlabLogo,
- helmLogo,
- jupyterhubLogo,
- kubernetesLogo,
- certManagerLogo,
- crossplaneLogo,
- knativeLogo,
- prometheusLogo,
- elasticStackLogo,
- },
-};
-</script>
-
-<template>
- <section id="cluster-applications">
- <p class="gl-mb-0">
- {{
- s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.`)
- }}
- <gl-link :href="helpPath">{{ __('More information') }}</gl-link>
- </p>
-
- <div class="cluster-application-list gl-mt-3">
- <application-row
- v-if="applications.helm.installed || applications.helm.uninstalling"
- id="helm"
- :logo-url="$options.logos.helmLogo"
- :title="applications.helm.title"
- :status="applications.helm.status"
- :status-reason="applications.helm.statusReason"
- :request-status="applications.helm.requestStatus"
- :request-reason="applications.helm.requestReason"
- :installed="applications.helm.installed"
- :install-failed="applications.helm.installFailed"
- :uninstallable="applications.helm.uninstallable"
- :uninstall-successful="applications.helm.uninstallSuccessful"
- :uninstall-failed="applications.helm.uninstallFailed"
- title-link="https://v2.helm.sh/"
- >
- <template #description>
- <p>
- {{
- s__(`ClusterIntegration|Can be safely removed. Prior to GitLab
- 13.2, GitLab used a remote Tiller server to manage the
- applications. GitLab no longer uses this server.
- Uninstalling this server will not affect your other
- applications. This row will disappear afterwards.`)
- }}
- <gl-link :href="helmHelpPath">{{ __('More information') }}</gl-link>
- </p>
- </template>
- </application-row>
- <application-row
- :id="ingressId"
- :logo-url="$options.logos.kubernetesLogo"
- :title="applications.ingress.title"
- :status="applications.ingress.status"
- :status-reason="applications.ingress.statusReason"
- :request-status="applications.ingress.requestStatus"
- :request-reason="applications.ingress.requestReason"
- :installed="applications.ingress.installed"
- :install-failed="applications.ingress.installFailed"
- :uninstallable="applications.ingress.uninstallable"
- :uninstall-successful="applications.ingress.uninstallSuccessful"
- :uninstall-failed="applications.ingress.uninstallFailed"
- :updateable="false"
- title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
- >
- <template #description>
- <p>
- {{
- s__(`ClusterIntegration|Ingress gives you a way to route
- requests to services based on the request host or path,
- centralizing a number of services into a single entrypoint.`)
- }}
- </p>
-
- <template v-if="ingressInstalled">
- <div class="form-group">
- <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
- <div class="input-group">
- <template v-if="ingressExternalEndpoint">
- <input
- id="ingress-endpoint"
- :value="ingressExternalEndpoint"
- type="text"
- class="form-control js-endpoint"
- readonly
- />
- <span class="input-group-append">
- <clipboard-button
- :text="ingressExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Ingress Endpoint')"
- class="input-group-text js-clipboard-btn"
- />
- </span>
- </template>
- <template v-else>
- <input type="text" class="form-control js-endpoint" readonly />
- <gl-loading-icon
- class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon"
- />
- </template>
- </div>
- <p class="form-text text-muted">
- {{
- s__(`ClusterIntegration|Point a wildcard DNS to this
- generated endpoint in order to access
- your application after it has been deployed.`)
- }}
- <gl-link :href="ingressDnsHelpPath" target="_blank">
- {{ __('More information') }}
- </gl-link>
- </p>
- </div>
-
- <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message">
- {{
- s__(`ClusterIntegration|The endpoint is in
- the process of being assigned. Please check your Kubernetes
- cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
- }}
- <gl-link :href="ingressDnsHelpPath" target="_blank">
- {{ __('More information') }}
- </gl-link>
- </p>
- </template>
- <template v-else>
- <gl-alert variant="info" :dismissible="false">
- <span data-testid="ingressCostWarning">
- <gl-sprintf
- :message="
- s__(
- 'ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{linkStart}pricing%{linkEnd}.',
- )
- "
- >
- <template #link="{ content }">
- <gl-link href="https://cloud.google.com/compute/pricing#lb" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </gl-alert>
- </template>
- </template>
- </application-row>
- <application-row
- id="cert_manager"
- :logo-url="$options.logos.certManagerLogo"
- :title="applications.cert_manager.title"
- :status="applications.cert_manager.status"
- :status-reason="applications.cert_manager.statusReason"
- :request-status="applications.cert_manager.requestStatus"
- :request-reason="applications.cert_manager.requestReason"
- :installed="applications.cert_manager.installed"
- :install-failed="applications.cert_manager.installFailed"
- :install-application-request-params="{ email: applications.cert_manager.email }"
- :uninstallable="applications.cert_manager.uninstallable"
- :uninstall-successful="applications.cert_manager.uninstallSuccessful"
- :uninstall-failed="applications.cert_manager.uninstallFailed"
- title-link="https://cert-manager.readthedocs.io/en/latest/#"
- >
- <template #description>
- <p data-testid="certManagerDescription">
- <gl-sprintf
- :message="
- s__(`ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates.
- Installing Cert-Manager on your cluster will issue a certificate by %{linkStart}Let's Encrypt%{linkEnd} and ensure that certificates
- are valid and up-to-date.`)
- "
- >
- <template #link="{ content }">
- <gl-link href="https://letsencrypt.org/" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- <div class="form-group">
- <label for="cert-manager-issuer-email">
- {{ s__('ClusterIntegration|Issuer Email') }}
- </label>
- <div class="input-group">
- <!-- eslint-disable vue/no-mutating-props -->
- <input
- id="cert-manager-issuer-email"
- v-model="applications.cert_manager.email"
- :readonly="certManagerInstalled"
- type="text"
- class="form-control js-email"
- />
- <!-- eslint-enable vue/no-mutating-props -->
- </div>
- <p class="form-text text-muted">
- {{
- s__(`ClusterIntegration|Issuers represent a certificate authority.
- You must provide an email address for your Issuer.`)
- }}
- <gl-link
- href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
- target="_blank"
- >{{ __('More information') }}</gl-link
- >
- </p>
- </div>
- </template>
- </application-row>
- <application-row
- id="prometheus"
- :logo-url="$options.logos.prometheusLogo"
- :title="applications.prometheus.title"
- :manage-link="managePrometheusPath"
- :status="applications.prometheus.status"
- :status-reason="applications.prometheus.statusReason"
- :request-status="applications.prometheus.requestStatus"
- :request-reason="applications.prometheus.requestReason"
- :installed="applications.prometheus.installed"
- :install-failed="applications.prometheus.installFailed"
- :uninstallable="applications.prometheus.uninstallable"
- :uninstall-successful="applications.prometheus.uninstallSuccessful"
- :uninstall-failed="applications.prometheus.uninstallFailed"
- title-link="https://prometheus.io/docs/introduction/overview/"
- >
- <template #description>
- <span data-testid="prometheusDescription">
- <gl-sprintf
- :message="
- s__(`ClusterIntegration|Prometheus is an open-source monitoring system
- with %{linkStart}GitLab Integration%{linkEnd} to monitor deployed applications.`)
- "
- >
- <template #link="{ content }">
- <gl-link
- href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </span>
- </template>
- </application-row>
- <application-row
- id="runner"
- :logo-url="$options.logos.gitlabLogo"
- :title="applications.runner.title"
- :status="applications.runner.status"
- :status-reason="applications.runner.statusReason"
- :request-status="applications.runner.requestStatus"
- :request-reason="applications.runner.requestReason"
- :version="applications.runner.version"
- :chart-repo="applications.runner.chartRepo"
- :update-available="applications.runner.updateAvailable"
- :installed="applications.runner.installed"
- :install-failed="applications.runner.installFailed"
- :update-successful="applications.runner.updateSuccessful"
- :update-failed="applications.runner.updateFailed"
- :uninstallable="applications.runner.uninstallable"
- :uninstall-successful="applications.runner.uninstallSuccessful"
- :uninstall-failed="applications.runner.uninstallFailed"
- title-link="https://docs.gitlab.com/runner/"
- >
- <template #description>
- {{
- s__(`ClusterIntegration|GitLab Runner connects to the
- repository and executes CI/CD jobs,
- pushing results back and deploying
- applications to production.`)
- }}
- </template>
- </application-row>
- <application-row
- id="crossplane"
- :logo-url="$options.logos.crossplaneLogo"
- :title="applications.crossplane.title"
- :status="applications.crossplane.status"
- :status-reason="applications.crossplane.statusReason"
- :request-status="applications.crossplane.requestStatus"
- :request-reason="applications.crossplane.requestReason"
- :installed="applications.crossplane.installed"
- :install-failed="applications.crossplane.installFailed"
- :uninstallable="applications.crossplane.uninstallable"
- :uninstall-successful="applications.crossplane.uninstallSuccessful"
- :uninstall-failed="applications.crossplane.uninstallFailed"
- :install-application-request-params="{ stack: applications.crossplane.stack }"
- title-link="https://crossplane.io"
- >
- <template #description>
- <p data-testid="crossplaneDescription">
- <gl-sprintf
- :message="
- s__(
- `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{codeStart}kubectl%{codeEnd} or %{linkStart}GitLab Integration%{linkEnd}.
- Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`,
- )
- "
- >
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- <template #link="{ content }">
- <gl-link
- href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </p>
- <div class="form-group">
- <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" />
- </div>
- </template>
- </application-row>
-
- <application-row
- id="jupyter"
- :logo-url="$options.logos.jupyterhubLogo"
- :title="applications.jupyter.title"
- :status="applications.jupyter.status"
- :status-reason="applications.jupyter.statusReason"
- :request-status="applications.jupyter.requestStatus"
- :request-reason="applications.jupyter.requestReason"
- :installed="applications.jupyter.installed"
- :install-failed="applications.jupyter.installFailed"
- :uninstallable="applications.jupyter.uninstallable"
- :uninstall-successful="applications.jupyter.uninstallSuccessful"
- :uninstall-failed="applications.jupyter.uninstallFailed"
- :install-application-request-params="{ hostname: applications.jupyter.hostname }"
- title-link="https://jupyterhub.readthedocs.io/en/stable/"
- >
- <template #description>
- <p>
- {{
- s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
- manages, and proxies multiple instances of the single-user
- Jupyter notebook server. JupyterHub can be used to serve
- notebooks to a class of students, a corporate data science group,
- or a scientific research group.`)
- }}
- <gl-sprintf
- :message="
- s__(
- 'ClusterIntegration|%{boldStart}Note:%{boldEnd} Requires Ingress to be installed.',
- )
- "
- >
- <template #bold="{ content }">
- <b>{{ content }}</b>
- </template>
- </gl-sprintf>
- </p>
-
- <template v-if="ingressExternalEndpoint">
- <div class="form-group">
- <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label>
-
- <div class="input-group">
- <!-- eslint-disable vue/no-mutating-props -->
- <input
- id="jupyter-hostname"
- v-model="applications.jupyter.hostname"
- :readonly="jupyterInstalled"
- type="text"
- class="form-control js-hostname"
- />
- <!-- eslint-enable vue/no-mutating-props -->
- <span class="input-group-append">
- <clipboard-button
- :text="jupyterHostname"
- :title="s__('ClusterIntegration|Copy Jupyter Hostname')"
- class="js-clipboard-btn"
- />
- </span>
- </div>
-
- <p v-if="ingressInstalled" class="form-text text-muted">
- {{
- s__(`ClusterIntegration|Replace this with your own hostname if you want.
- If you do so, point hostname to Ingress IP Address from above.`)
- }}
- <gl-link :href="ingressDnsHelpPath" target="_blank">
- {{ __('More information') }}
- </gl-link>
- </p>
- </div>
- </template>
- </template>
- </application-row>
- <application-row
- id="knative"
- :logo-url="$options.logos.knativeLogo"
- :title="applications.knative.title"
- :status="applications.knative.status"
- :status-reason="applications.knative.statusReason"
- :request-status="applications.knative.requestStatus"
- :request-reason="applications.knative.requestReason"
- :installed="applications.knative.installed"
- :install-failed="applications.knative.installFailed"
- :install-application-request-params="{
- hostname: applications.knative.hostname,
- pages_domain_id: applications.knative.pagesDomain && applications.knative.pagesDomain.id,
- }"
- :uninstallable="applications.knative.uninstallable"
- :uninstall-successful="applications.knative.uninstallSuccessful"
- :uninstall-failed="applications.knative.uninstallFailed"
- :updateable="false"
- v-bind="applications.knative"
- title-link="https://github.com/knative/docs"
- >
- <template #description>
- <gl-alert v-if="!rbac" variant="info" class="rbac-notice gl-my-3" :dismissible="false">
- {{
- s__(`ClusterIntegration|You must have an RBAC-enabled cluster
- to install Knative.`)
- }}
- <gl-link :href="helpPath" target="_blank">{{ __('More information') }}</gl-link>
- </gl-alert>
- <p>
- {{
- s__(`ClusterIntegration|Knative extends Kubernetes to provide
- a set of middleware components that are essential to build modern,
- source-centric, and container-based applications that can run
- anywhere: on premises, in the cloud, or even in a third-party data center.`)
- }}
- </p>
-
- <knative-domain-editor
- v-if="(knative.installed || rbac) && !preInstalledKnative"
- :knative="knative"
- :ingress-dns-help-path="ingressDnsHelpPath"
- @save="saveKnativeDomain"
- @set="setKnativeDomain"
- />
- </template>
- <template v-if="cloudRun" #installed-via>
- <span data-testid="installed-via">
- <gl-sprintf
- :message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')"
- >
- <template #link="{ content }">
- <gl-link :href="cloudRunHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </template>
- </application-row>
- <application-row
- id="elastic_stack"
- :logo-url="$options.logos.elasticStackLogo"
- :title="applications.elastic_stack.title"
- :status="applications.elastic_stack.status"
- :status-reason="applications.elastic_stack.statusReason"
- :request-status="applications.elastic_stack.requestStatus"
- :request-reason="applications.elastic_stack.requestReason"
- :version="applications.elastic_stack.version"
- :chart-repo="applications.elastic_stack.chartRepo"
- :update-available="applications.elastic_stack.updateAvailable"
- :installed="applications.elastic_stack.installed"
- :install-failed="applications.elastic_stack.installFailed"
- :update-successful="applications.elastic_stack.updateSuccessful"
- :update-failed="applications.elastic_stack.updateFailed"
- :uninstallable="applications.elastic_stack.uninstallable"
- :uninstall-successful="applications.elastic_stack.uninstallSuccessful"
- :uninstall-failed="applications.elastic_stack.uninstallFailed"
- title-link="https://gitlab.com/gitlab-org/charts/elastic-stack"
- >
- <template #description>
- <p>
- {{
- s__(
- `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`,
- )
- }}
- </p>
- </template>
- </application-row>
-
- <div class="gl-mt-7 gl-border-1 gl-border-t-solid gl-border-gray-100">
- <!-- This empty div serves as a separator. The applications below can be externally installed using a cluster-management project. -->
- </div>
-
- <application-row
- id="cilium"
- :title="applications.cilium.title"
- :logo-url="$options.logos.gitlabLogo"
- :status="applications.cilium.status"
- :status-reason="applications.cilium.statusReason"
- :installable="applications.cilium.installable"
- :uninstallable="applications.cilium.uninstallable"
- :installed="applications.cilium.installed"
- :install-failed="applications.cilium.installFailed"
- :title-link="ciliumHelpPath"
- >
- <template #description>
- <p data-testid="ciliumDescription">
- <gl-sprintf
- :message="
- s__(
- 'ClusterIntegration|Protect your clusters with GitLab Container Network Policies by enforcing how pods communicate with each other and other network endpoints. %{linkStart}Learn more about configuring Network Policies here.%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link :href="ciliumHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </template>
- </application-row>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
deleted file mode 100644
index 6b99bb09504..00000000000
--- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
-import { s__ } from '../../locale';
-
-export default {
- name: 'CrossplaneProviderStack',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- },
- props: {
- stacks: {
- type: Array,
- required: false,
- default: () => [
- {
- name: s__('Google Cloud Platform'),
- code: 'gcp',
- },
- {
- name: s__('Amazon Web Services'),
- code: 'aws',
- },
- {
- name: s__('Microsoft Azure'),
- code: 'azure',
- },
- {
- name: s__('Rook'),
- code: 'rook',
- },
- ],
- },
- crossplane: {
- type: Object,
- required: true,
- },
- },
- computed: {
- dropdownText() {
- const result = this.stacks.reduce((map, obj) => {
- // eslint-disable-next-line no-param-reassign
- map[obj.code] = obj.name;
- return map;
- }, {});
- const { stack } = this.crossplane;
- if (stack !== '') {
- return result[stack];
- }
- return s__('Select Stack');
- },
- validationError() {
- return this.crossplane.validationError;
- },
- },
- methods: {
- selectStack(stack) {
- this.$emit('set', stack);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <label>
- {{ s__('ClusterIntegration|Enabled stack') }}
- </label>
- <gl-dropdown
- :disabled="crossplane.installed"
- :text="dropdownText"
- toggle-class="dropdown-menu-toggle gl-field-error-outline"
- class="w-100"
- :class="{ 'gl-show-field-errors': validationError }"
- >
- <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)">
- <span class="ml-1">{{ stack.name }}</span>
- </gl-dropdown-item>
- </gl-dropdown>
- <span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
- <p class="form-text text-muted">
- {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }}
- <a
- href="https://crossplane.io/docs/master/stacks-guide.html"
- target="_blank"
- rel="noopener noreferrer"
- >{{ __('Crossplane') }}
- <gl-icon name="external-link" class="vertical-align-middle" />
- </a>
- </p>
- </div>
-</template>
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
deleted file mode 100644
index 89446680173..00000000000
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ /dev/null
@@ -1,232 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlSprintf,
- GlButton,
- GlAlert,
-} from '@gitlab/ui';
-import { APPLICATION_STATUS } from '~/clusters/constants';
-import { __, s__ } from '~/locale';
-
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-
-const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
-
-export default {
- components: {
- GlButton,
- ClipboardButton,
- GlLoadingIcon,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlSearchBoxByType,
- GlSprintf,
- GlAlert,
- },
- props: {
- knative: {
- type: Object,
- required: true,
- },
- ingressDnsHelpPath: {
- type: String,
- default: '',
- required: false,
- },
- },
- data() {
- return {
- searchQuery: '',
- };
- },
- computed: {
- saveButtonDisabled() {
- return [UNINSTALLING, UPDATING].includes(this.knative.status);
- },
- saving() {
- return [UPDATING].includes(this.knative.status);
- },
- saveButtonLabel() {
- return this.saving ? __('Saving') : __('Save changes');
- },
- knativeInstalled() {
- return this.knative.installed;
- },
- knativeExternalEndpoint() {
- return this.knative.externalIp || this.knative.externalHostname;
- },
- knativeUpdateSuccessful() {
- return this.knative.updateSuccessful;
- },
- knativeHostname: {
- get() {
- return this.knative.hostname;
- },
- set(hostname) {
- this.selectCustomDomain(hostname);
- },
- },
- domainDropdownText() {
- return this.knativeHostname || s__('ClusterIntegration|Select existing domain or use new');
- },
- availableDomains() {
- return this.knative.availableDomains || [];
- },
- filteredDomains() {
- const query = this.searchQuery.toLowerCase();
- return this.availableDomains.filter(({ domain }) => domain.toLowerCase().includes(query));
- },
- showDomainsDropdown() {
- return this.availableDomains.length > 0;
- },
- validationError() {
- return this.knative.validationError;
- },
- },
- watch: {
- knativeUpdateSuccessful(updateSuccessful) {
- if (updateSuccessful) {
- this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.'));
- }
- },
- },
- methods: {
- selectDomain({ id, domain }) {
- this.$emit('set', { domain, domainId: id });
- },
- selectCustomDomain(domain) {
- this.$emit('set', { domain, domainId: null });
- },
- },
-};
-</script>
-
-<template>
- <div class="row">
- <gl-alert
- v-if="knative.updateFailed"
- class="gl-mb-5 col-12 js-cluster-knative-domain-name-failure-message"
- variant="danger"
- >
- {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }}
- </gl-alert>
-
- <div
- :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }"
- class="form-group col-sm-12 mb-0"
- >
- <label for="knative-domainname">
- <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
- </label>
-
- <gl-dropdown
- v-if="showDomainsDropdown"
- :text="domainDropdownText"
- toggle-class="dropdown-menu-toggle"
- class="w-100 mb-2"
- >
- <gl-search-box-by-type
- v-model.trim="searchQuery"
- :placeholder="s__('ClusterIntegration|Search domains')"
- />
- <gl-dropdown-item
- v-for="domain in filteredDomains"
- :key="domain.id"
- @click="selectDomain(domain)"
- >
- <span class="ml-1">{{ domain.domain }}</span>
- </gl-dropdown-item>
- <template v-if="searchQuery">
- <gl-dropdown-divider />
- <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)">
- <span class="ml-1">
- <gl-sprintf :message="s__('ClusterIntegration|Use %{query}')">
- <template #query>
- <code>{{ searchQuery }}</code>
- </template>
- </gl-sprintf>
- </span>
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
-
- <input
- v-else
- id="knative-domainname"
- v-model="knativeHostname"
- type="text"
- class="form-control js-knative-domainname"
- />
-
- <span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
- </div>
-
- <template v-if="knativeInstalled">
- <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0">
- <label for="knative-endpoint">
- <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong>
- </label>
- <div v-if="knativeExternalEndpoint" class="input-group">
- <input
- id="knative-endpoint"
- :value="knativeExternalEndpoint"
- type="text"
- class="form-control js-knative-endpoint"
- readonly
- />
- <span class="input-group-append">
- <clipboard-button
- :text="knativeExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Knative Endpoint')"
- class="input-group-text js-knative-endpoint-clipboard-btn"
- />
- </span>
- </div>
- <div v-else class="input-group">
- <input type="text" class="form-control js-endpoint" readonly />
- <gl-loading-icon
- class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon"
- />
- </div>
- </div>
-
- <p class="form-text text-muted col-12">
- {{
- s__(
- `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
- )
- }}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
- __('More information')
- }}</a>
- </p>
-
- <p
- v-if="!knativeExternalEndpoint"
- class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3"
- >
- {{
- s__(`ClusterIntegration|The endpoint is in
- the process of being assigned. Please check your Kubernetes
- cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
- }}
- </p>
-
- <gl-button
- class="js-knative-save-domain-button gl-mt-5 gl-ml-5"
- variant="success"
- category="primary"
- :loading="saving"
- :disabled="saveButtonDisabled"
- @click="$emit('save')"
- >
- {{ saveButtonLabel }}
- </gl-button>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
deleted file mode 100644
index 73191d6d84d..00000000000
--- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { APPLICATION_STATUS } from '~/clusters/constants';
-import { __ } from '~/locale';
-
-const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
-
-export default {
- components: {
- GlButton,
- },
- props: {
- status: {
- type: String,
- required: true,
- },
- },
- computed: {
- disabled() {
- return [UNINSTALLING, UPDATING].includes(this.status);
- },
- loading() {
- return this.status === UNINSTALLING;
- },
- label() {
- return this.loading ? __('Uninstalling') : __('Uninstall');
- },
- },
-};
-</script>
-
-<template>
- <gl-button :disabled="disabled" variant="default" :loading="loading">
- {{ label }}
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
deleted file mode 100644
index 2a197e40b60..00000000000
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
-import { sprintf, s__ } from '~/locale';
-import {
- HELM,
- INGRESS,
- CERT_MANAGER,
- PROMETHEUS,
- RUNNER,
- KNATIVE,
- JUPYTER,
- ELASTIC_STACK,
-} from '../constants';
-
-const CUSTOM_APP_WARNING_TEXT = {
- [HELM]: sprintf(
- s__(
- 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored. Your other applications will remain unaffected.',
- ),
- {
- gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>',
- },
- false,
- ),
- [INGRESS]: s__(
- 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
- ),
- [CERT_MANAGER]: s__(
- 'ClusterIntegration|The associated private key will be deleted and cannot be restored.',
- ),
- [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
- [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
- [KNATIVE]: s__(
- 'ClusterIntegration|The associated IP and all deployed services will be deleted and cannot be restored. Uninstalling Knative will also remove Istio from your cluster. This will not effect any other applications.',
- ),
- [JUPYTER]: s__(
- 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.',
- ),
- [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
-};
-
-export default {
- components: {
- GlModal,
- },
- directives: {
- SafeHtml,
- },
- mixins: [trackUninstallButtonClickMixin],
- props: {
- application: {
- type: String,
- required: true,
- },
- applicationTitle: {
- type: String,
- required: true,
- },
- },
- computed: {
- title() {
- return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), {
- appTitle: this.applicationTitle,
- });
- },
- warningText() {
- return sprintf(
- s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'),
- {
- appTitle: this.applicationTitle,
- },
- );
- },
- customAppWarningText() {
- return CUSTOM_APP_WARNING_TEXT[this.application];
- },
- modalId() {
- return `uninstall-${this.application}`;
- },
- },
- methods: {
- confirmUninstall() {
- this.trackUninstallButtonClick(this.application);
- this.$emit('confirm');
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ok-variant="danger"
- cancel-variant="light"
- :ok-title="title"
- :modal-id="modalId"
- :title="title"
- @ok="confirmUninstall()"
- >
- {{ warningText }} <span v-safe-html="customAppWarningText"></span>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue
deleted file mode 100644
index 0aedc6e84fa..00000000000
--- a/app/assets/javascripts/clusters/components/update_application_confirmation_modal.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import { GlModal } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
-import { ELASTIC_STACK } from '../constants';
-
-const CUSTOM_APP_WARNING_TEXT = {
- [ELASTIC_STACK]: s__(
- 'ClusterIntegration|Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.',
- ),
-};
-
-export default {
- components: {
- GlModal,
- },
- props: {
- application: {
- type: String,
- required: true,
- },
- applicationTitle: {
- type: String,
- required: true,
- },
- },
- computed: {
- title() {
- return sprintf(s__('ClusterIntegration|Update %{appTitle}'), {
- appTitle: this.applicationTitle,
- });
- },
- warningText() {
- return sprintf(
- s__('ClusterIntegration|You are about to update %{appTitle} on your cluster.'),
- {
- appTitle: this.applicationTitle,
- },
- );
- },
- customAppWarningText() {
- return CUSTOM_APP_WARNING_TEXT[this.application];
- },
- modalId() {
- return `update-${this.application}`;
- },
- },
- methods: {
- confirmUpdate() {
- this.$emit('confirm');
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ok-variant="danger"
- cancel-variant="light"
- :ok-title="title"
- :modal-id="modalId"
- :title="title"
- @ok="confirmUpdate()"
- >
- {{ warningText }} <span v-html="customAppWarningText"></span>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 846e5950b8b..c6ca895778d 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -10,64 +10,7 @@ export const PROVIDER_TYPE = {
GCP: 'gcp',
};
-// These need to match what is returned from the server
-export const APPLICATION_STATUS = {
- NO_STATUS: null,
- NOT_INSTALLABLE: 'not_installable',
- INSTALLABLE: 'installable',
- SCHEDULED: 'scheduled',
- INSTALLING: 'installing',
- INSTALLED: 'installed',
- UPDATING: 'updating',
- UPDATED: 'updated',
- UPDATE_ERRORED: 'update_errored',
- UNINSTALLING: 'uninstalling',
- UNINSTALL_ERRORED: 'uninstall_errored',
- ERROR: 'errored',
- PRE_INSTALLED: 'pre_installed',
- UNINSTALLED: 'uninstalled',
- EXTERNALLY_INSTALLED: 'externally_installed',
-};
-
-/*
- * The application cannot be in any of the following states without
- * not being installed.
- */
-export const APPLICATION_INSTALLED_STATUSES = [
- APPLICATION_STATUS.INSTALLED,
- APPLICATION_STATUS.UPDATING,
- APPLICATION_STATUS.UNINSTALLING,
- APPLICATION_STATUS.PRE_INSTALLED,
-];
-
// These are only used client-side
-export const UPDATE_EVENT = 'update';
-export const INSTALL_EVENT = 'install';
-export const UNINSTALL_EVENT = 'uninstall';
-
-export const HELM = 'helm';
-export const INGRESS = 'ingress';
-export const JUPYTER = 'jupyter';
-export const KNATIVE = 'knative';
-export const RUNNER = 'runner';
-export const CERT_MANAGER = 'cert_manager';
-export const CROSSPLANE = 'crossplane';
-export const PROMETHEUS = 'prometheus';
-export const ELASTIC_STACK = 'elastic_stack';
-
-export const APPLICATIONS = [
- HELM,
- INGRESS,
- JUPYTER,
- KNATIVE,
- RUNNER,
- CERT_MANAGER,
- PROMETHEUS,
- ELASTIC_STACK,
-];
-
-export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
-
export const LOGGING_MODE = 'logging';
export const BLOCKING_MODE = 'blocking';
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
deleted file mode 100644
index 2ff604af9a7..00000000000
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ /dev/null
@@ -1,250 +0,0 @@
-import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants';
-
-const {
- NO_STATUS,
- SCHEDULED,
- NOT_INSTALLABLE,
- INSTALLABLE,
- INSTALLING,
- INSTALLED,
- ERROR,
- UPDATING,
- UPDATED,
- UPDATE_ERRORED,
- UNINSTALLING,
- UNINSTALL_ERRORED,
- PRE_INSTALLED,
- UNINSTALLED,
- EXTERNALLY_INSTALLED,
-} = APPLICATION_STATUS;
-
-const applicationStateMachine = {
- /* When the application initially loads, it will have `NO_STATUS`
- * It will transition from `NO_STATUS` once the async backend call is completed
- */
- [NO_STATUS]: {
- on: {
- [SCHEDULED]: {
- target: INSTALLING,
- },
- [NOT_INSTALLABLE]: {
- target: NOT_INSTALLABLE,
- },
- [INSTALLABLE]: {
- target: INSTALLABLE,
- },
- [INSTALLING]: {
- target: INSTALLING,
- },
- [INSTALLED]: {
- target: INSTALLED,
- },
- [ERROR]: {
- target: INSTALLABLE,
- effects: {
- installFailed: true,
- },
- },
- [UPDATING]: {
- target: UPDATING,
- },
- [UPDATED]: {
- target: INSTALLED,
- },
- [UPDATE_ERRORED]: {
- target: INSTALLED,
- effects: {
- updateFailed: true,
- },
- },
- [UNINSTALLING]: {
- target: UNINSTALLING,
- },
- [UNINSTALL_ERRORED]: {
- target: INSTALLED,
- effects: {
- uninstallFailed: true,
- },
- },
- [PRE_INSTALLED]: {
- target: PRE_INSTALLED,
- },
- [UNINSTALLED]: {
- target: UNINSTALLED,
- },
- [EXTERNALLY_INSTALLED]: {
- target: EXTERNALLY_INSTALLED,
- },
- },
- },
- [NOT_INSTALLABLE]: {
- on: {
- [INSTALLABLE]: {
- target: INSTALLABLE,
- },
- },
- },
- [INSTALLABLE]: {
- on: {
- [INSTALL_EVENT]: {
- target: INSTALLING,
- effects: {
- installFailed: false,
- },
- },
- [NOT_INSTALLABLE]: {
- target: NOT_INSTALLABLE,
- },
- [INSTALLED]: {
- target: INSTALLED,
- effects: {
- installFailed: false,
- },
- },
- [UNINSTALLED]: {
- target: UNINSTALLED,
- effects: {
- installFailed: false,
- },
- },
- },
- },
- [INSTALLING]: {
- on: {
- [INSTALLED]: {
- target: INSTALLED,
- },
- [ERROR]: {
- target: INSTALLABLE,
- effects: {
- installFailed: true,
- },
- },
- },
- },
- [INSTALLED]: {
- on: {
- [UPDATE_EVENT]: {
- target: UPDATING,
- effects: {
- updateFailed: false,
- updateSuccessful: false,
- },
- },
- [NOT_INSTALLABLE]: {
- target: NOT_INSTALLABLE,
- },
- [UNINSTALL_EVENT]: {
- target: UNINSTALLING,
- effects: {
- uninstallFailed: false,
- uninstallSuccessful: false,
- },
- },
- [UNINSTALLED]: {
- target: UNINSTALLED,
- },
- [ERROR]: {
- target: INSTALLABLE,
- effects: {
- installFailed: true,
- },
- },
- },
- },
- [PRE_INSTALLED]: {
- on: {
- [UPDATE_EVENT]: {
- target: UPDATING,
- effects: {
- updateFailed: false,
- updateSuccessful: false,
- },
- },
- [NOT_INSTALLABLE]: {
- target: NOT_INSTALLABLE,
- },
- [UNINSTALL_EVENT]: {
- target: UNINSTALLING,
- effects: {
- uninstallFailed: false,
- uninstallSuccessful: false,
- },
- },
- },
- },
- [UPDATING]: {
- on: {
- [UPDATED]: {
- target: INSTALLED,
- effects: {
- updateSuccessful: true,
- },
- },
- [UPDATE_ERRORED]: {
- target: INSTALLED,
- effects: {
- updateFailed: true,
- },
- },
- },
- },
- [UNINSTALLING]: {
- on: {
- [INSTALLABLE]: {
- target: INSTALLABLE,
- effects: {
- uninstallSuccessful: true,
- },
- },
- [NOT_INSTALLABLE]: {
- target: NOT_INSTALLABLE,
- effects: {
- uninstallSuccessful: true,
- },
- },
- [UNINSTALL_ERRORED]: {
- target: INSTALLED,
- effects: {
- uninstallFailed: true,
- },
- },
- },
- },
- [UNINSTALLED]: {
- on: {
- [INSTALLED]: {
- target: INSTALLED,
- },
- [ERROR]: {
- target: INSTALLABLE,
- effects: {
- installFailed: true,
- },
- },
- },
- },
-};
-
-/**
- * Determines an application new state based on the application current state
- * and an event. If the application current state cannot handle a given event,
- * the current state is returned.
- *
- * @param {*} application
- * @param {*} event
- */
-const transitionApplicationState = (application, event) => {
- const stateMachine = applicationStateMachine[application.status];
- const newState = stateMachine !== undefined ? stateMachine.on[event] : false;
-
- return newState
- ? {
- ...application,
- status: newState.target,
- ...newState.effects,
- }
- : application;
-};
-
-export default transitionApplicationState;
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 333fb293a15..7300bb3137a 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -3,38 +3,12 @@ import axios from '../../lib/utils/axios_utils';
export default class ClusterService {
constructor(options = {}) {
this.options = options;
- this.appInstallEndpointMap = {
- helm: this.options.installHelmEndpoint,
- ingress: this.options.installIngressEndpoint,
- cert_manager: this.options.installCertManagerEndpoint,
- crossplane: this.options.installCrossplaneEndpoint,
- runner: this.options.installRunnerEndpoint,
- prometheus: this.options.installPrometheusEndpoint,
- jupyter: this.options.installJupyterEndpoint,
- knative: this.options.installKnativeEndpoint,
- elastic_stack: this.options.installElasticStackEndpoint,
- };
- this.appUpdateEndpointMap = {
- knative: this.options.updateKnativeEndpoint,
- };
}
fetchClusterStatus() {
return axios.get(this.options.endpoint);
}
- installApplication(appId, params) {
- return axios.post(this.appInstallEndpointMap[appId], params);
- }
-
- updateApplication(appId, params) {
- return axios.patch(this.appUpdateEndpointMap[appId], params);
- }
-
- uninstallApplication(appId, params) {
- return axios.delete(this.appInstallEndpointMap[appId], params);
- }
-
fetchClusterEnvironments() {
return axios.get(this.options.clusterEnvironmentsEndpoint);
}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 50689a6142f..db6e7bad6cc 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,112 +1,16 @@
import { parseBoolean } from '../../lib/utils/common_utils';
-import { s__ } from '../../locale';
-import {
- INGRESS,
- JUPYTER,
- KNATIVE,
- CERT_MANAGER,
- CROSSPLANE,
- RUNNER,
- APPLICATION_INSTALLED_STATUSES,
- APPLICATION_STATUS,
- INSTALL_EVENT,
- UPDATE_EVENT,
- UNINSTALL_EVENT,
- ELASTIC_STACK,
-} from '../constants';
-import transitionApplicationState from '../services/application_state_machine';
-
-const isApplicationInstalled = (appStatus) => APPLICATION_INSTALLED_STATUSES.includes(appStatus);
-
-const applicationInitialState = {
- status: null,
- statusReason: null,
- requestReason: null,
- installable: true,
- installed: false,
- installFailed: false,
- uninstallable: false,
- uninstallFailed: false,
- uninstallSuccessful: false,
- validationError: null,
-};
export default class ClusterStore {
constructor() {
this.state = {
helpPath: null,
- helmHelpPath: null,
- ingressHelpPath: null,
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
- cloudRunHelpPath: null,
status: null,
providerType: null,
- preInstalledKnative: false,
rbac: false,
statusReason: null,
- applications: {
- helm: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Legacy Helm Tiller server'),
- },
- ingress: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Ingress'),
- externalIp: null,
- externalHostname: null,
- updateFailed: false,
- updateAvailable: false,
- },
- cert_manager: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Cert-Manager'),
- email: null,
- },
- crossplane: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Crossplane'),
- stack: null,
- },
- runner: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|GitLab Runner'),
- version: null,
- chartRepo: 'https://gitlab.com/gitlab-org/charts/gitlab-runner',
- updateAvailable: null,
- updateSuccessful: false,
- updateFailed: false,
- },
- prometheus: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Prometheus'),
- },
- jupyter: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|JupyterHub'),
- hostname: null,
- },
- knative: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Knative'),
- hostname: null,
- isEditingDomain: false,
- externalIp: null,
- externalHostname: null,
- updateSuccessful: false,
- updateFailed: false,
- },
- elastic_stack: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|Elastic Stack'),
- },
- cilium: {
- ...applicationInitialState,
- title: s__('ClusterIntegration|GitLab Container Network Policies'),
- installable: false,
- },
- },
environments: [],
fetchingEnvironments: false,
};
@@ -118,10 +22,6 @@ export default class ClusterStore {
});
}
- setManagePrometheusPath(managePrometheusPath) {
- this.state.managePrometheusPath = managePrometheusPath;
- }
-
updateStatus(status) {
this.state.status = status;
}
@@ -130,10 +30,6 @@ export default class ClusterStore {
this.state.providerType = providerType;
}
- updatePreInstalledKnative(preInstalledKnative) {
- this.state.preInstalledKnative = parseBoolean(preInstalledKnative);
- }
-
updateRbac(rbac) {
this.state.rbac = parseBoolean(rbac);
}
@@ -142,112 +38,9 @@ export default class ClusterStore {
this.state.statusReason = reason;
}
- installApplication(appId) {
- this.handleApplicationEvent(appId, INSTALL_EVENT);
- }
-
- notifyInstallFailure(appId) {
- this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR);
- }
-
- updateApplication(appId) {
- this.handleApplicationEvent(appId, UPDATE_EVENT);
- }
-
- notifyUpdateFailure(appId) {
- this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
- }
-
- uninstallApplication(appId) {
- this.handleApplicationEvent(appId, UNINSTALL_EVENT);
- }
-
- notifyUninstallFailure(appId) {
- this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED);
- }
-
- handleApplicationEvent(appId, event) {
- const currentAppState = this.state.applications[appId];
-
- this.state.applications[appId] = transitionApplicationState(currentAppState, event);
- }
-
- updateAppProperty(appId, prop, value) {
- this.state.applications[appId][prop] = value;
- }
-
updateStateFromServer(serverState = {}) {
this.state.status = serverState.status;
this.state.statusReason = serverState.status_reason;
-
- serverState.applications.forEach((serverAppEntry) => {
- const {
- name: appId,
- status,
- status_reason: statusReason,
- version,
- update_available: updateAvailable,
- can_uninstall: uninstallable,
- } = serverAppEntry;
- const currentApplicationState = this.state.applications[appId] || {};
- const nextApplicationState = transitionApplicationState(currentApplicationState, status);
-
- this.state.applications[appId] = {
- ...currentApplicationState,
- ...nextApplicationState,
- statusReason,
- installed: isApplicationInstalled(nextApplicationState.status),
- uninstallable,
- };
-
- if (appId === INGRESS) {
- this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
- this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname;
- this.state.applications.ingress.updateAvailable = updateAvailable;
- } else if (appId === CERT_MANAGER) {
- this.state.applications.cert_manager.email =
- this.state.applications.cert_manager.email || serverAppEntry.email;
- } else if (appId === CROSSPLANE) {
- this.state.applications.crossplane.stack =
- this.state.applications.crossplane.stack || serverAppEntry.stack;
- } else if (appId === JUPYTER) {
- this.state.applications.jupyter.hostname = this.updateHostnameIfUnset(
- this.state.applications.jupyter.hostname,
- serverAppEntry.hostname,
- 'jupyter',
- );
- } else if (appId === KNATIVE) {
- if (serverAppEntry.available_domains) {
- this.state.applications.knative.availableDomains = serverAppEntry.available_domains;
- }
- if (!this.state.applications.knative.isEditingDomain) {
- this.state.applications.knative.pagesDomain =
- serverAppEntry.pages_domain || this.state.applications.knative.pagesDomain;
- this.state.applications.knative.hostname =
- serverAppEntry.hostname || this.state.applications.knative.hostname;
- }
- this.state.applications.knative.externalIp =
- serverAppEntry.external_ip || this.state.applications.knative.externalIp;
- this.state.applications.knative.externalHostname =
- serverAppEntry.external_hostname || this.state.applications.knative.externalHostname;
- } else if (appId === RUNNER) {
- this.state.applications.runner.version = version;
- this.state.applications.runner.updateAvailable = updateAvailable;
- } else if (appId === ELASTIC_STACK) {
- this.state.applications.elastic_stack.version = version;
- this.state.applications.elastic_stack.updateAvailable = updateAvailable;
- }
- });
- }
-
- updateHostnameIfUnset(current, updated, fallback) {
- return (
- current ||
- updated ||
- (this.state.applications.ingress.externalIp
- ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io`
- : '')
- );
}
toggleFetchEnvironments(isFetching) {
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 40a86a1e58c..5f35a0b26f3 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
@@ -64,7 +64,9 @@ export const fetchClusters = ({ state, commit, dispatch }) => {
commit(types.SET_LOADING_CLUSTERS, false);
commit(types.SET_LOADING_NODES, false);
- flash(__('Clusters|An error occurred while loading clusters'));
+ createFlash({
+ message: __('Clusters|An error occurred while loading clusters'),
+ });
dispatch('reportSentryError', { error: response, tag: 'fetchClustersErrorCallback' });
},
diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js
index 97c80f6eff7..894ec9a171d 100644
--- a/app/assets/javascripts/code_quality_walkthrough/utils.js
+++ b/app/assets/javascripts/code_quality_walkthrough/utils.js
@@ -1,6 +1,7 @@
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
import { getExperimentData } from '~/experimentation/utils';
-import { setCookie, getCookie, getParameterByName } from '~/lib/utils/common_utils';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import { EXPERIMENT_NAME } from './constants';
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 29ad6cc4125..8d88b682df2 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import CommitPipelinesTable from './pipelines_table.vue';
/**
* Used in:
@@ -14,25 +13,24 @@ export default () => {
if (pipelineTableViewEl) {
// Update MR and Commits tabs
pipelineTableViewEl.addEventListener('update-pipelines-count', (event) => {
- if (
- event.detail.pipelines &&
- event.detail.pipelines.count &&
- event.detail.pipelines.count.all
- ) {
+ if (event.detail.pipelineCount) {
const badge = document.querySelector('.js-pipelines-mr-count');
- badge.textContent = event.detail.pipelines.count.all;
+ badge.textContent = event.detail.pipelineCount;
}
});
if (pipelineTableViewEl.dataset.disableInitialization === undefined) {
const table = new Vue({
+ components: {
+ CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
+ },
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
},
render(createElement) {
- return createElement(CommitPipelinesTable, {
+ return createElement('commit-pipelines-table', {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index ddca5bc7d4f..42d46dc3d5d 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
-import { getParameterByName } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import eventHub from '~/pipelines/event_hub';
import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
@@ -133,15 +133,15 @@ export default {
this.store.storePagination(resp.headers);
this.setCommonData(pipelines);
- const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
- detail: {
- pipelines: resp.data,
- },
- });
+ if (resp.headers?.['x-total']) {
+ const updatePipelinesEvent = new CustomEvent('update-pipelines-count', {
+ detail: { pipelineCount: resp.headers['x-total'] },
+ });
- // notifiy to update the count in tabs
- if (this.$el.parentElement) {
- this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
+ // notifiy to update the count in tabs
+ if (this.$el.parentElement) {
+ this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
+ }
}
},
/**
@@ -251,7 +251,7 @@ export default {
}}
</p>
<gl-link
- href="/help/ci/merge_request_pipelines/index.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
+ href="/help/ci/pipelines/merge_request_pipelines.html#run-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project"
target="_blank"
>
{{ s__('Pipelines|More Information') }}
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
index e382356841c..f973bf51b57 100644
--- a/app/assets/javascripts/commit_merge_requests.js
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -1,6 +1,5 @@
-/* global Flash */
-
import $ from 'jquery';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { n__, s__ } from './locale';
@@ -71,5 +70,9 @@ export function fetchCommitMergeRequests() {
$container.html($content);
})
- .catch(() => Flash(s__('Commits|An error occurred while fetching merge requests data.')));
+ .catch(() =>
+ createFlash({
+ message: s__('Commits|An error occurred while fetching merge requests data.'),
+ }),
+ );
}
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index da7fc88d8ac..39dc4a4e9e5 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -93,7 +93,7 @@ export default class CommitsList {
.text(n__('%d commit', '%d commits', commitsCount));
}
- localTimeAgo($processedData.find('.js-timeago'));
+ localTimeAgo($processedData.find('.js-timeago').get());
return processedData;
}
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 6b07b7e3772..5f778af1dbb 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
import Api from '../../api';
-import createFlash from '../../flash';
import { __ } from '../../locale';
import state from '../state';
import Dropdown from './dropdown.vue';
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index c6ab2e189ef..9a51def7075 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,10 +1,12 @@
<script>
+import { GlAlert } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { ContentEditor } from '../services/content_editor';
import TopToolbar from './top_toolbar.vue';
export default {
components: {
+ GlAlert,
TiptapEditorContent,
TopToolbar,
},
@@ -14,15 +16,30 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ error: '',
+ };
+ },
+ mounted() {
+ this.contentEditor.tiptapEditor.on('error', (error) => {
+ this.error = error;
+ });
+ },
};
</script>
<template>
- <div
- data-testid="content-editor"
- class="md-area"
- :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
- >
- <top-toolbar class="gl-mb-4" :content-editor="contentEditor" />
- <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
+ <div>
+ <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''">
+ {{ error }}
+ </gl-alert>
+ <div
+ data-testid="content-editor"
+ class="md-area"
+ :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
+ >
+ <top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" />
+ <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
new file mode 100644
index 00000000000..ebeee16dbec
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
@@ -0,0 +1,110 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownForm,
+ GlButton,
+ GlFormInputGroup,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import { acceptedMimes } from '../extensions/image';
+import { getImageAlt } from '../services/utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownForm,
+ GlFormInputGroup,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ imgSrc: '',
+ };
+ },
+ methods: {
+ resetFields() {
+ this.imgSrc = '';
+ this.$refs.fileSelector.value = '';
+ },
+ insertImage() {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .setImage({
+ src: this.imgSrc,
+ canonicalSrc: this.imgSrc,
+ alt: getImageAlt(this.imgSrc),
+ })
+ .run();
+
+ this.resetFields();
+ this.emitExecute();
+ },
+ emitExecute(source = 'url') {
+ this.$emit('execute', { contentType: 'image', value: source });
+ },
+ openFileUpload() {
+ this.$refs.fileSelector.click();
+ },
+ onFileSelect(e) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .uploadImage({
+ file: e.target.files[0],
+ })
+ .run();
+
+ this.resetFields();
+ this.emitExecute('upload');
+ },
+ },
+ acceptedMimes,
+};
+</script>
+<template>
+ <gl-dropdown
+ v-gl-tooltip
+ :aria-label="__('Insert image')"
+ :title="__('Insert image')"
+ size="small"
+ category="tertiary"
+ icon="media"
+ @hidden="resetFields()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="openFileUpload">
+ {{ __('Upload image') }}
+ </gl-dropdown-item>
+
+ <input
+ ref="fileSelector"
+ type="file"
+ name="content_editor_image"
+ :accept="$options.acceptedMimes"
+ class="gl-display-none"
+ @change="onFileSelect"
+ />
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
index f706080eaa1..8f57959a73f 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
@@ -43,14 +43,22 @@ export default {
},
mounted() {
this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
- const { href } = editor.getAttributes(linkContentType);
+ const { canonicalSrc, href } = editor.getAttributes(linkContentType);
- this.linkHref = href;
+ this.linkHref = canonicalSrc || href;
});
},
methods: {
updateLink() {
- this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run();
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .unsetLink()
+ .setLink({
+ href: this.linkHref,
+ canonicalSrc: this.linkHref,
+ })
+ .run();
this.$emit('execute', { contentType: linkContentType });
},
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
new file mode 100644
index 00000000000..49d3006e9bf
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import { __, sprintf } from '~/locale';
+import { clamp } from '../services/utils';
+
+export const tableContentType = 'table';
+
+const MIN_ROWS = 3;
+const MIN_COLS = 3;
+const MAX_ROWS = 8;
+const MAX_COLS = 8;
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownForm,
+ GlButton,
+ },
+ props: {
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ maxRows: MIN_ROWS,
+ maxCols: MIN_COLS,
+ rows: 1,
+ cols: 1,
+ };
+ },
+ methods: {
+ list(n) {
+ return new Array(n).fill().map((_, i) => i + 1);
+ },
+ setRowsAndCols(rows, cols) {
+ this.rows = rows;
+ this.cols = cols;
+ this.maxRows = clamp(rows + 1, MIN_ROWS, MAX_ROWS);
+ this.maxCols = clamp(cols + 1, MIN_COLS, MAX_COLS);
+ },
+ resetState() {
+ this.rows = 1;
+ this.cols = 1;
+ },
+ insertTable() {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .insertTable({
+ rows: this.rows,
+ cols: this.cols,
+ withHeaderRow: true,
+ })
+ .run();
+
+ this.resetState();
+
+ this.$emit('execute', { contentType: 'table' });
+ },
+ getButtonLabel(rows, cols) {
+ return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols });
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown size="small" category="tertiary" icon="table">
+ <gl-dropdown-form class="gl-px-3! gl-w-auto!">
+ <div class="gl-w-auto!">
+ <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
+ <gl-button
+ v-for="c of list(maxCols)"
+ :key="c"
+ :data-testid="`table-${r}-${c}`"
+ :class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }"
+ :aria-label="getButtonLabel(r, c)"
+ class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!"
+ @mouseover="setRowsAndCols(r, c)"
+ @click="insertTable()"
+ />
+ </div>
+ <gl-dropdown-divider />
+ {{ getButtonLabel(rows, cols) }}
+ </div>
+ </gl-dropdown-form>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index d3363ce092b..fafc7a660e7 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -4,7 +4,9 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '
import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
+import ToolbarImageButton from './toolbar_image_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue';
+import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
@@ -16,6 +18,8 @@ export default {
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
+ ToolbarTableButton,
+ ToolbarImageButton,
Divider,
},
mixins: [trackingMixin],
@@ -87,6 +91,12 @@ export default {
@execute="trackToolbarControlExecution"
/>
<divider />
+ <toolbar-image-button
+ ref="imageButton"
+ data-testid="image"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
<toolbar-button
data-testid="blockquote"
content-type="blockquote"
@@ -123,5 +133,23 @@ export default {
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
+ <toolbar-button
+ data-testid="horizontal-rule"
+ content-type="horizontalRule"
+ icon-name="dash"
+ editor-command="setHorizontalRule"
+ :label="__('Add a horizontal rule')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
</div>
</template>
+<style>
+.gl-spinner-container {
+ text-align: left;
+}
+</style>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
new file mode 100644
index 00000000000..3762324a431
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+
+export default {
+ name: 'ImageWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLoadingIcon,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span class="gl-relative">
+ <img
+ data-testid="image"
+ class="gl-max-w-full gl-h-auto"
+ :class="{ 'gl-opacity-5': node.attrs.uploading }"
+ :src="node.attrs.src"
+ />
+ <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
+ </span>
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js
index dc1ba431151..756eefa875c 100644
--- a/app/assets/javascripts/content_editor/extensions/hard_break.js
+++ b/app/assets/javascripts/content_editor/extensions/hard_break.js
@@ -1,5 +1,13 @@
import { HardBreak } from '@tiptap/extension-hard-break';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-export const tiptapExtension = HardBreak;
+const ExtendedHardBreak = HardBreak.extend({
+ addKeyboardShortcuts() {
+ return {
+ 'Shift-Enter': () => this.editor.commands.setHardBreak(),
+ };
+ },
+});
+
+export const tiptapExtension = ExtendedHardBreak;
export const serializer = defaultMarkdownSerializer.nodes.hard_break;
diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
index dcc59476518..c287938af5c 100644
--- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
+++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
@@ -1,5 +1,12 @@
+import { nodeInputRule } from '@tiptap/core';
import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-export const tiptapExtension = HorizontalRule;
+export const hrInputRuleRegExp = /^---$/;
+
+export const tiptapExtension = HorizontalRule.extend({
+ addInputRules() {
+ return [nodeInputRule(hrInputRuleRegExp, this.type)];
+ },
+});
export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule;
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 287216e68d5..4dd8a1376ad 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,10 +1,65 @@
import { Image } from '@tiptap/extension-image';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { __ } from '~/locale';
+import ImageWrapper from '../components/wrappers/image.vue';
+import { uploadFile } from '../services/upload_file';
+import { getImageAlt, readFileAsDataURL } from '../services/utils';
+
+export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
+
+const resolveImageEl = (element) =>
+ element.nodeName === 'IMG' ? element : element.querySelector('img');
+
+const startFileUpload = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+ const encodedSrc = await readFileAsDataURL(file);
+ const { view } = editor;
+
+ editor.commands.setImage({ uploading: true, src: encodedSrc });
+
+ const { state } = view;
+ const position = state.selection.from - 1;
+ const { tr } = state;
+
+ try {
+ const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+
+ view.dispatch(
+ tr.setNodeMarkup(position, undefined, {
+ uploading: false,
+ src: encodedSrc,
+ alt: getImageAlt(src),
+ canonicalSrc,
+ }),
+ );
+ } catch (e) {
+ editor.commands.deleteRange({ from: position, to: position + 1 });
+ editor.emit('error', __('An error occurred while uploading the image. Please try again.'));
+ }
+};
+
+const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
+ if (acceptedMimes.includes(file?.type)) {
+ startFileUpload({ editor, file, uploadsPath, renderMarkdown });
+
+ return true;
+ }
+
+ return false;
+};
const ExtendedImage = Image.extend({
+ defaultOptions: {
+ ...Image.options,
+ uploadsPath: null,
+ renderMarkdown: null,
+ },
addAttributes() {
return {
...this.parent?.(),
+ uploading: {
+ default: false,
+ },
src: {
default: null,
/*
@@ -14,17 +69,25 @@ const ExtendedImage = Image.extend({
* attribute.
*/
parseHTML: (element) => {
- const img = element.querySelector('img');
+ const img = resolveImageEl(element);
return {
src: img.dataset.src || img.getAttribute('src'),
};
},
},
+ canonicalSrc: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ canonicalSrc: element.dataset.canonicalSrc,
+ };
+ },
+ },
alt: {
default: null,
parseHTML: (element) => {
- const img = element.querySelector('img');
+ const img = resolveImageEl(element);
return {
alt: img.getAttribute('alt'),
@@ -44,7 +107,62 @@ const ExtendedImage = Image.extend({
},
];
},
-}).configure({ inline: true });
+ addCommands() {
+ return {
+ ...this.parent(),
+ uploadImage: ({ file }) => () => {
+ const { uploadsPath, renderMarkdown } = this.options;
+
+ handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ const { editor } = this;
+
+ return [
+ new Plugin({
+ key: new PluginKey('handleDropAndPasteImages'),
+ props: {
+ handlePaste: (_, event) => {
+ const { uploadsPath, renderMarkdown } = this.options;
+
+ return handleFileEvent({
+ editor,
+ file: event.clipboardData.files[0],
+ uploadsPath,
+ renderMarkdown,
+ });
+ },
+ handleDrop: (_, event) => {
+ const { uploadsPath, renderMarkdown } = this.options;
+
+ return handleFileEvent({
+ editor,
+ file: event.dataTransfer.files[0],
+ uploadsPath,
+ renderMarkdown,
+ });
+ },
+ },
+ }),
+ ];
+ },
+ addNodeView() {
+ return VueNodeViewRenderer(ImageWrapper);
+ },
+});
+
+const serializer = (state, node) => {
+ const { alt, canonicalSrc, src, title } = node.attrs;
+ const quotedTitle = title ? ` ${state.quote(title)}` : '';
+
+ state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
+};
-export const tiptapExtension = ExtendedImage;
-export const serializer = defaultMarkdownSerializer.nodes.image;
+export const configure = ({ renderMarkdown, uploadsPath }) => {
+ return {
+ tiptapExtension: ExtendedImage.configure({ inline: true, renderMarkdown, uploadsPath }),
+ serializer,
+ };
+};
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 6f5f81cbf93..12019ab4636 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -1,9 +1,7 @@
import { markInputRule } from '@tiptap/core';
import { Link } from '@tiptap/extension-link';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
-
export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
const extractHrefFromMatch = (match) => {
@@ -29,8 +27,37 @@ export const tiptapExtension = Link.extend({
markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch),
];
},
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ href: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ href: element.getAttribute('href'),
+ };
+ },
+ },
+ canonicalSrc: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ canonicalSrc: element.dataset.canonicalSrc,
+ };
+ },
+ },
+ };
+ },
}).configure({
openOnClick: false,
});
-export const serializer = defaultMarkdownSerializer.marks.link;
+export const serializer = {
+ open() {
+ return '[';
+ },
+ close(state, mark) {
+ const href = mark.attrs.canonicalSrc || mark.attrs.href;
+ return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
+ },
+};
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
new file mode 100644
index 00000000000..566f7a21a85
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -0,0 +1,7 @@
+import { Table } from '@tiptap/extension-table';
+
+export const tiptapExtension = Table;
+
+export function serializer(state, node) {
+ state.renderContent(node);
+}
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
new file mode 100644
index 00000000000..6c25b867466
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -0,0 +1,9 @@
+import { TableCell } from '@tiptap/extension-table-cell';
+
+export const tiptapExtension = TableCell.extend({
+ content: 'inline*',
+});
+
+export function serializer(state, node) {
+ state.renderInline(node);
+}
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
new file mode 100644
index 00000000000..3475857b9e6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -0,0 +1,9 @@
+import { TableHeader } from '@tiptap/extension-table-header';
+
+export const tiptapExtension = TableHeader.extend({
+ content: 'inline*',
+});
+
+export function serializer(state, node) {
+ state.renderInline(node);
+}
diff --git a/app/assets/javascripts/content_editor/extensions/table_row.js b/app/assets/javascripts/content_editor/extensions/table_row.js
new file mode 100644
index 00000000000..07d2eb4faa2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_row.js
@@ -0,0 +1,51 @@
+import { TableRow } from '@tiptap/extension-table-row';
+
+export const tiptapExtension = TableRow.extend({
+ allowGapCursor: false,
+});
+
+export function serializer(state, node) {
+ const isHeaderRow = node.child(0).type.name === 'tableHeader';
+
+ const renderRow = () => {
+ const cellWidths = [];
+
+ state.flushClose(1);
+
+ state.write('| ');
+ node.forEach((cell, _, i) => {
+ if (i) state.write(' | ');
+
+ const { length } = state.out;
+ state.render(cell, node, i);
+ cellWidths.push(state.out.length - length);
+ });
+ state.write(' |');
+
+ state.closeBlock(node);
+
+ return cellWidths;
+ };
+
+ const renderHeaderRow = (cellWidths) => {
+ state.flushClose(1);
+
+ state.write('|');
+ node.forEach((cell, _, i) => {
+ if (i) state.write('|');
+
+ state.write(cell.attrs.align === 'center' ? ':' : '-');
+ state.write(state.repeat('-', cellWidths[i]));
+ state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
+ });
+ state.write('|');
+
+ state.closeBlock(node);
+ };
+
+ if (isHeaderRow) {
+ renderHeaderRow(renderRow());
+ } else {
+ renderRow();
+ }
+}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 8a54da6f57d..9251fdbbdc5 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -20,35 +20,16 @@ import * as ListItem from '../extensions/list_item';
import * as OrderedList from '../extensions/ordered_list';
import * as Paragraph from '../extensions/paragraph';
import * as Strike from '../extensions/strike';
+import * as Table from '../extensions/table';
+import * as TableCell from '../extensions/table_cell';
+import * as TableHeader from '../extensions/table_header';
+import * as TableRow from '../extensions/table_row';
import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
-const builtInContentEditorExtensions = [
- Blockquote,
- Bold,
- BulletList,
- Code,
- CodeBlockHighlight,
- Document,
- Dropcursor,
- Gapcursor,
- HardBreak,
- Heading,
- History,
- HorizontalRule,
- Image,
- Italic,
- Link,
- ListItem,
- OrderedList,
- Paragraph,
- Strike,
- Text,
-];
-
const collectTiptapExtensions = (extensions = []) =>
extensions.map(({ tiptapExtension }) => tiptapExtension);
@@ -63,11 +44,43 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
...options,
});
-export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => {
+export const createContentEditor = ({
+ renderMarkdown,
+ uploadsPath,
+ extensions = [],
+ tiptapOptions,
+} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
+ const builtInContentEditorExtensions = [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ Document,
+ Dropcursor,
+ Gapcursor,
+ HardBreak,
+ Heading,
+ History,
+ HorizontalRule,
+ Image.configure({ uploadsPath, renderMarkdown }),
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Paragraph,
+ Strike,
+ TableCell,
+ TableHeader,
+ TableRow,
+ Table,
+ Text,
+ ];
+
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js
new file mode 100644
index 00000000000..613c53144a1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/upload_file.js
@@ -0,0 +1,44 @@
+import axios from '~/lib/utils/axios_utils';
+
+const extractAttachmentLinkUrl = (html) => {
+ const parser = new DOMParser();
+ const { body } = parser.parseFromString(html, 'text/html');
+ const link = body.querySelector('a');
+ const src = link.getAttribute('href');
+ const { canonicalSrc } = link.dataset;
+
+ return { src, canonicalSrc };
+};
+
+/**
+ * Uploads a file with a post request to the URL indicated
+ * in the uploadsPath parameter. The expected response of the
+ * uploads service is a JSON object that contains, at least, a
+ * link property. The link property should contain markdown link
+ * definition (i.e. [GitLab](https://gitlab.com)).
+ *
+ * This Markdown will be rendered to extract its canonical and full
+ * URLs using GitLab Flavored Markdown renderer in the backend.
+ *
+ * @param {Object} params
+ * @param {String} params.uploadsPath An absolute URL that points to a service
+ * that allows sending a file for uploading via POST request.
+ * @param {String} params.renderMarkdown A function that accepts a markdown string
+ * and returns a rendered version in HTML format.
+ * @param {File} params.file The file to upload
+ *
+ * @returns Returns an object with two properties:
+ *
+ * canonicalSrc: The URL as defined in the Markdown
+ * src: The absolute URL that points to the resource in the server
+ */
+export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+
+ const { data } = await axios.post(uploadsPath, formData);
+ const { markdown } = data.link;
+ const rendered = await renderMarkdown(markdown);
+
+ return extractAttachmentLinkUrl(rendered);
+};
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index cf5234bbff8..2a2c7f617da 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -3,3 +3,17 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
+
+export const getImageAlt = (src) => {
+ return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' ');
+};
+
+export const readFileAsDataURL = (file) => {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
+ reader.readAsDataURL(file);
+ });
+};
+
+export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 25ce6500094..512f060e2ea 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -204,15 +204,16 @@ export default {
<h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
<resizable-chart-container>
- <gl-area-chart
- slot-scope="{ width }"
- class="gl-mb-5"
- :width="width"
- :data="masterChartData"
- :option="masterChartOptions"
- :height="masterChartHeight"
- @created="onMasterChartCreated"
- />
+ <template #default="{ width }">
+ <gl-area-chart
+ class="gl-mb-5"
+ :width="width"
+ :data="masterChartData"
+ :option="masterChartOptions"
+ :height="masterChartHeight"
+ @created="onMasterChartCreated"
+ />
+ </template>
</resizable-chart-container>
<div class="row">
@@ -226,14 +227,15 @@ export default {
{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
</p>
<resizable-chart-container>
- <gl-area-chart
- slot-scope="{ width }"
- :width="width"
- :data="contributor.dates"
- :option="individualChartOptions"
- :height="individualChartHeight"
- @created="onIndividualChartCreated"
- />
+ <template #default="{ width }">
+ <gl-area-chart
+ :width="width"
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
+ </template>
</resizable-chart-container>
</div>
</div>
diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js
index 72aae3af692..4cc0a6a6509 100644
--- a/app/assets/javascripts/contributors/stores/actions.js
+++ b/app/assets/javascripts/contributors/stores/actions.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import service from '../services/contributors_service';
import * as types from './mutation_types';
@@ -13,5 +13,9 @@ export const fetchChartData = ({ commit }, endpoint) => {
commit(types.SET_CHART_DATA, data);
commit(types.SET_LOADING_STATE, false);
})
- .catch(() => flash(__('An error occurred while loading chart data')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while loading chart data'),
+ }),
+ );
};
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
index 6b18455bfcc..23c477bfbfd 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
@@ -95,7 +95,7 @@ export default {
</li>
</ul>
</div>
- <div class="dropdown-loading"><gl-loading-icon /></div>
+ <div class="dropdown-loading"><gl-loading-icon size="sm" /></div>
</div>
</div>
<span
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index b6f0bdbf01d..aba6dd4b493 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -160,7 +160,7 @@ export default {
</li>
</ul>
</div>
- <div class="dropdown-loading"><gl-loading-icon /></div>
+ <div class="dropdown-loading"><gl-loading-icon size="sm" /></div>
</div>
</div>
<span
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
index daab42c7e60..027ce74753e 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
@@ -84,7 +84,7 @@ export default {
</li>
</ul>
</div>
- <div class="dropdown-loading"><gl-loading-icon /></div>
+ <div class="dropdown-loading"><gl-loading-icon size="sm" /></div>
</div>
</div>
<span
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js
index 4eafbdb7265..3a42b460e1c 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue';
import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue';
import GkeSubmitButton from './components/gke_submit_button.vue';
@@ -59,7 +59,9 @@ const mountGkeSubmitButton = () => {
};
const gkeDropdownErrorHandler = () => {
- Flash(CONSTANTS.GCP_API_ERROR);
+ createFlash({
+ message: CONSTANTS.GCP_API_ERROR,
+ });
};
const initializeGapiClient = (gapi) => () => {
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
index 0aae63e1648..411e482b0ce 100644
--- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
+++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue
@@ -218,7 +218,7 @@ export default {
@input="debouncedValidateQuery"
/>
<span v-if="queryValidateInFlight" class="form-text text-muted">
- <gl-loading-icon :inline="true" class="mr-1 align-middle" />
+ <gl-loading-icon size="sm" :inline="true" class="mr-1 align-middle" />
{{ s__('Metrics|Validating query') }}
</span>
<slot v-if="!queryValidateInFlight" name="valid-feedback">
diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
new file mode 100644
index 00000000000..5140b05e189
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
@@ -0,0 +1,142 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
+import {
+ OPERATOR_IS_ONLY,
+ DEFAULT_NONE_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ prepareTokens,
+ processFilters,
+ filterToQueryObject,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
+import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+export default {
+ name: 'FilterBar',
+ components: {
+ FilteredSearchBar,
+ UrlSync,
+ },
+ props: {
+ groupPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState('filters', {
+ selectedMilestone: (state) => state.milestones.selected,
+ selectedAuthor: (state) => state.authors.selected,
+ selectedLabelList: (state) => state.labels.selectedList,
+ selectedAssigneeList: (state) => state.assignees.selectedList,
+ milestonesData: (state) => state.milestones.data,
+ labelsData: (state) => state.labels.data,
+ authorsData: (state) => state.authors.data,
+ assigneesData: (state) => state.assignees.data,
+ }),
+ tokens() {
+ return [
+ {
+ icon: 'clock',
+ title: __('Milestone'),
+ type: 'milestone',
+ token: MilestoneToken,
+ initialMilestones: this.milestonesData,
+ unique: true,
+ symbol: '%',
+ operators: OPERATOR_IS_ONLY,
+ fetchMilestones: this.fetchMilestones,
+ },
+ {
+ icon: 'labels',
+ title: __('Label'),
+ type: 'labels',
+ token: LabelToken,
+ defaultLabels: DEFAULT_NONE_ANY,
+ initialLabels: this.labelsData,
+ unique: false,
+ symbol: '~',
+ operators: OPERATOR_IS_ONLY,
+ fetchLabels: this.fetchLabels,
+ },
+ {
+ icon: 'pencil',
+ title: __('Author'),
+ type: 'author',
+ token: AuthorToken,
+ initialAuthors: this.authorsData,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ fetchAuthors: this.fetchAuthors,
+ },
+ {
+ icon: 'user',
+ title: __('Assignees'),
+ type: 'assignees',
+ token: AuthorToken,
+ defaultAuthors: [],
+ initialAuthors: this.assigneesData,
+ unique: false,
+ operators: OPERATOR_IS_ONLY,
+ fetchAuthors: this.fetchAssignees,
+ },
+ ];
+ },
+ query() {
+ return filterToQueryObject({
+ milestone_title: this.selectedMilestone,
+ author_username: this.selectedAuthor,
+ label_name: this.selectedLabelList,
+ assignee_username: this.selectedAssigneeList,
+ });
+ },
+ },
+ methods: {
+ ...mapActions('filters', [
+ 'setFilters',
+ 'fetchMilestones',
+ 'fetchLabels',
+ 'fetchAuthors',
+ 'fetchAssignees',
+ ]),
+ initialFilterValue() {
+ return prepareTokens({
+ milestone: this.selectedMilestone,
+ author: this.selectedAuthor,
+ assignees: this.selectedAssigneeList,
+ labels: this.selectedLabelList,
+ });
+ },
+ handleFilter(filters) {
+ const { labels, milestone, author, assignees } = processFilters(filters);
+
+ this.setFilters({
+ selectedAuthor: author ? author[0] : null,
+ selectedMilestone: milestone ? milestone[0] : null,
+ selectedAssigneeList: assignees || [],
+ selectedLabelList: labels || [],
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <filtered-search-bar
+ class="gl-flex-grow-1"
+ :namespace="groupPath"
+ recent-searches-storage-key="value-stream-analytics"
+ :search-input-placeholder="__('Filter results')"
+ :tokens="tokens"
+ :initial-filter-value="initialFilterValue()"
+ @onFilter="handleFilter"
+ />
+ <url-sync :query="query" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue
new file mode 100644
index 00000000000..b622b0441e2
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue
@@ -0,0 +1,32 @@
+<script>
+import { s__, n__, sprintf, formatNumber } from '~/locale';
+
+export default {
+ props: {
+ stageCount: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ formattedStageCount() {
+ if (!this.stageCount) {
+ return '-';
+ } else if (this.stageCount > 1000) {
+ return sprintf(s__('ValueStreamAnalytics|%{stageCount}+ items'), {
+ stageCount: formatNumber(1000),
+ });
+ }
+
+ return sprintf(n__('%{count} item', '%{count} items', this.stageCount), {
+ count: formatNumber(this.stageCount),
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <span>{{ formattedStageCount }}</span>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
index c1e33f73b13..47fafc3b90c 100644
--- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
@@ -7,6 +7,7 @@ import {
} from '@gitlab/ui';
import Tracking from '~/tracking';
import { OVERVIEW_STAGE_ID } from '../constants';
+import FormattedStageCount from './formatted_stage_count.vue';
export default {
name: 'PathNavigation',
@@ -14,6 +15,7 @@ export default {
GlPath,
GlSkeletonLoading,
GlPopover,
+ FormattedStageCount,
},
directives: {
SafeHtml,
@@ -44,9 +46,6 @@ export default {
showPopover({ id }) {
return id && id !== OVERVIEW_STAGE_ID;
},
- hasStageCount({ stageCount = null }) {
- return stageCount !== null;
- },
onSelectStage($event) {
this.$emit('selected', $event);
this.track('click_path_navigation', {
@@ -88,10 +87,7 @@ export default {
{{ s__('ValueStreamEvent|Items in stage') }}
</div>
<div class="gl-pb-4 gl-font-weight-bold">
- <template v-if="hasStageCount(pathItem)">{{
- n__('%d item', '%d items', pathItem.stageCount)
- }}</template>
- <template v-else>-</template>
+ <formatted-stage-count :stage-count="pathItem.stageCount" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
new file mode 100644
index 00000000000..6b1e537dc77
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -0,0 +1,93 @@
+<script>
+import DateRange from '~/analytics/shared/components/daterange.vue';
+import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
+import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants';
+import FilterBar from './filter_bar.vue';
+
+export default {
+ name: 'ValueStreamFilters',
+ components: {
+ DateRange,
+ ProjectsDropdownFilter,
+ FilterBar,
+ },
+ props: {
+ selectedProjects: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ hasProjectFilter: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ hasDateRangeFilter: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ groupPath: {
+ type: String,
+ required: true,
+ },
+ startDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ endDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ projectsQueryParams() {
+ return {
+ first: PROJECTS_PER_PAGE,
+ includeSubgroups: true,
+ };
+ },
+ },
+ multiProjectSelect: true,
+ maxDateRange: DATE_RANGE_LIMIT,
+};
+</script>
+<template>
+ <div class="gl-mt-3 gl-py-2 gl-px-3 bg-gray-light border-top border-bottom">
+ <filter-bar
+ class="js-filter-bar filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
+ :group-path="groupPath"
+ />
+ <div
+ v-if="hasDateRangeFilter || hasProjectFilter"
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-justify-content-space-between"
+ >
+ <projects-dropdown-filter
+ v-if="hasProjectFilter"
+ :key="groupId"
+ class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
+ :group-id="groupId"
+ :group-namespace="groupPath"
+ :query-params="projectsQueryParams"
+ :multi-select="$options.multiProjectSelect"
+ :default-projects="selectedProjects"
+ @selected="$emit('selectProject', $event)"
+ />
+ <date-range
+ v-if="hasDateRangeFilter"
+ :start-date="startDate"
+ :end-date="endDate"
+ :max-date-range="$options.maxDateRange"
+ :include-selected-date="true"
+ class="js-daterange-picker"
+ @change="$emit('setDateRange', $event)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index 96c89049e90..97f502326e5 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -1,3 +1,4 @@
+export const DEFAULT_DAYS_IN_PAST = 30;
export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 57cb220d9c9..615f96c3860 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -8,11 +8,24 @@ Vue.use(Translate);
export default () => {
const store = createStore();
const el = document.querySelector('#js-cycle-analytics');
- const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset;
+ const {
+ noAccessSvgPath,
+ noDataSvgPath,
+ requestPath,
+ fullPath,
+ projectId,
+ groupPath,
+ } = el.dataset;
store.dispatch('initializeVsa', {
+ projectId: parseInt(projectId, 10),
+ groupPath,
requestPath,
fullPath,
+ features: {
+ cycleAnalyticsForGroups:
+ (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false,
+ },
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index faf1c37d86a..955f0c7271e 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -3,6 +3,7 @@ import {
getProjectValueStreams,
getProjectValueStreamStageData,
getProjectValueStreamMetrics,
+ getValueStreamStageMedian,
} from '~/api/analytics_api';
import createFlash from '~/flash';
import { __ } from '~/locale';
@@ -35,21 +36,33 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
};
export const fetchValueStreams = ({ commit, dispatch, state }) => {
- const { fullPath } = state;
+ const {
+ fullPath,
+ features: { cycleAnalyticsForGroups },
+ } = state;
commit(types.REQUEST_VALUE_STREAMS);
+ const stageRequests = ['setSelectedStage'];
+ if (cycleAnalyticsForGroups) {
+ stageRequests.push('fetchStageMedians');
+ }
+
return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
- .then(() => dispatch('setSelectedStage'))
+ .then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
.catch(({ response: { status } }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
});
};
-export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => {
+export const fetchCycleAnalyticsData = ({
+ state: { requestPath },
+ getters: { legacyFilterParams },
+ commit,
+}) => {
commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
- return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate })
+ return getProjectValueStreamMetrics(requestPath, legacyFilterParams)
.then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data))
.catch(() => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
@@ -59,13 +72,17 @@ export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, com
});
};
-export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => {
+export const fetchStageData = ({
+ state: { requestPath, selectedStage },
+ getters: { legacyFilterParams },
+ commit,
+}) => {
commit(types.REQUEST_STAGE_DATA);
return getProjectValueStreamStageData({
requestPath,
stageId: selectedStage.id,
- params: { 'cycle_analytics[start_date]': startDate },
+ params: legacyFilterParams,
})
.then(({ data }) => {
// when there's a query timeout, the request succeeds but the error is encoded in the response data
@@ -78,6 +95,37 @@ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate
.catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR));
};
+const getStageMedians = ({ stageId, vsaParams, filterParams = {} }) => {
+ return getValueStreamStageMedian({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({
+ id: stageId,
+ value: data?.value || null,
+ }));
+};
+
+export const fetchStageMedians = ({
+ state: { stages },
+ getters: { requestParams: vsaParams, filterParams },
+ commit,
+}) => {
+ commit(types.REQUEST_STAGE_MEDIANS);
+ return Promise.all(
+ stages.map(({ id: stageId }) =>
+ getStageMedians({
+ vsaParams,
+ stageId,
+ filterParams,
+ }),
+ ),
+ )
+ .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data))
+ .catch((error) => {
+ commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error);
+ createFlash({
+ message: __('There was an error fetching median data for stages'),
+ });
+ });
+};
+
export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => {
const stage = selectedStage || stages[0];
commit(types.SET_SELECTED_STAGE, stage);
@@ -92,6 +140,8 @@ const refetchData = (dispatch, commit) => {
.finally(() => commit(types.SET_LOADING, false));
};
+export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit);
+
export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => {
commit(types.SET_DATE_RANGE, { startDate });
return refetchData(dispatch, commit);
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index c60a70ef147..66971ea8a2e 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,3 +1,5 @@
+import dateFormat from 'dateformat';
+import { dateFormats } from '~/analytics/shared/constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
@@ -8,3 +10,30 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage
selectedStage,
});
};
+
+export const requestParams = (state) => {
+ const {
+ selectedStage: { id: stageId = null },
+ groupPath: groupId,
+ selectedValueStream: { id: valueStreamId },
+ } = state;
+ return { valueStreamId, groupId, stageId };
+};
+
+const dateRangeParams = ({ createdAfter, createdBefore }) => ({
+ created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null,
+ created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
+});
+
+export const legacyFilterParams = ({ startDate }) => {
+ return {
+ 'cycle_analytics[start_date]': startDate,
+ };
+};
+
+export const filterParams = ({ id, ...rest }) => {
+ return {
+ project_ids: [id],
+ ...dateRangeParams(rest),
+ };
+};
diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js
index c6ca88ea492..76e3e835016 100644
--- a/app/assets/javascripts/cycle_analytics/store/index.js
+++ b/app/assets/javascripts/cycle_analytics/store/index.js
@@ -7,6 +7,7 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
@@ -20,4 +21,5 @@ export default () =>
getters,
mutations,
state,
+ modules: { filters },
});
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 4f3d430ec9f..11ed62a4081 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -20,3 +20,7 @@ export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_
export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
+
+export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
+export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
+export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 0ae80116cd2..a8b7a607b66 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -1,11 +1,23 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { decorateData, decorateEvents, formatMedianValues } from '../utils';
+import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
+import {
+ decorateData,
+ decorateEvents,
+ formatMedianValues,
+ calculateFormattedDayInPast,
+} from '../utils';
import * as types from './mutation_types';
export default {
- [types.INITIALIZE_VSA](state, { requestPath, fullPath }) {
+ [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) {
state.requestPath = requestPath;
state.fullPath = fullPath;
+ state.groupPath = groupPath;
+ state.id = projectId;
+ const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
+ state.createdBefore = now;
+ state.createdAfter = past;
+ state.features = features;
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
@@ -18,6 +30,9 @@ export default {
},
[types.SET_DATE_RANGE](state, { startDate }) {
state.startDate = startDate;
+ const { now, past } = calculateFormattedDayInPast(startDate);
+ state.createdBefore = now;
+ state.createdAfter = past;
},
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
@@ -46,17 +61,25 @@ export default {
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.hasError = false;
+ if (!state.features.cycleAnalyticsForGroups) {
+ state.medians = {};
+ }
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
const { summary, medians } = decorateData(data);
+ if (!state.features.cycleAnalyticsForGroups) {
+ state.medians = formatMedianValues(medians);
+ }
state.permissions = data.permissions;
state.summary = summary;
- state.medians = formatMedianValues(medians);
state.hasError = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
state.isLoading = false;
state.hasError = true;
+ if (!state.features.cycleAnalyticsForGroups) {
+ state.medians = {};
+ }
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
@@ -78,4 +101,13 @@ export default {
state.hasError = true;
state.selectedStageError = error;
},
+ [types.REQUEST_STAGE_MEDIANS](state) {
+ state.medians = {};
+ },
+ [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians) {
+ state.medians = formatMedianValues(medians);
+ },
+ [types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
+ state.medians = {};
+ },
};
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 02f953d9517..4d61077fb99 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -1,9 +1,13 @@
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
+ features: {},
+ id: null,
requestPath: '',
fullPath: '',
startDate: DEFAULT_DAYS_TO_DISPLAY,
+ createdAfter: null,
+ createdBefore: null,
stages: [],
summary: [],
analytics: [],
@@ -19,4 +23,5 @@ export default () => ({
isLoadingStage: false,
isEmptyStage: false,
permissions: {},
+ parentPath: null,
});
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index 40ad7d8b2fc..a1690dd1513 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,6 +1,9 @@
+import dateFormat from 'dateformat';
import { unescape } from 'lodash';
+import { dateFormats } from '~/analytics/shared/constants';
import { sanitize } from '~/lib/dompurify';
import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '../locale';
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
@@ -115,3 +118,20 @@ export const formatMedianValues = (medians = []) =>
export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
+
+const toIsoFormat = (d) => dateFormat(d, dateFormats.isoDate);
+
+/**
+ * Takes an integer specifying the number of days to subtract
+ * from the date specified will return the 2 dates, formatted as ISO dates
+ *
+ * @param {Number} daysInPast - Number of days in the past to subtract
+ * @param {Date} [today=new Date] - Date to subtract days from, defaults to today
+ * @returns {Object} Returns 'now' and the 'past' date formatted as ISO dates
+ */
+export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
+ return {
+ now: toIsoFormat(today),
+ past: toIsoFormat(getDateInPast(today, daysInPast)),
+ };
+};
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 02c57164f47..36d54f586f1 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import eventHub from '../eventhub';
@@ -93,14 +93,20 @@ export default {
.catch(() => {
this.isLoading = false;
this.store.keys = {};
- return new Flash(s__('DeployKeys|Error getting deploy keys'));
+ return createFlash({
+ message: s__('DeployKeys|Error getting deploy keys'),
+ });
});
},
enableKey(deployKey) {
this.service
.enableKey(deployKey.id)
.then(this.fetchKeys)
- .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key')));
+ .catch(() =>
+ createFlash({
+ message: s__('DeployKeys|Error enabling deploy key'),
+ }),
+ );
},
confirmRemoveKey(deployKey, callback) {
const hideModal = () => {
@@ -112,7 +118,11 @@ export default {
.disableKey(deployKey.id)
.then(this.fetchKeys)
.then(hideModal)
- .catch(() => new Flash(s__('DeployKeys|Error removing deploy key')));
+ .catch(() =>
+ createFlash({
+ message: s__('DeployKeys|Error removing deploy key'),
+ }),
+ );
};
this.cancel = hideModal;
this.confirmModalVisible = true;
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index b1c37b0687f..78ba586ce37 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -221,7 +221,7 @@ export default {
@click.stop="toggleResolvedStatus"
>
<gl-icon v-if="!isResolving" :name="resolveIconName" data-testid="resolve-icon" />
- <gl-loading-icon v-else inline />
+ <gl-loading-icon v-else size="sm" inline />
</button>
</template>
<template v-if="discussion.resolved" #resolved-status>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 833d7081a2c..1e1f5135290 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -48,6 +49,9 @@ export default {
author() {
return this.note.author;
},
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
noteAnchorId() {
return findNoteId(this.note.id);
},
@@ -94,7 +98,7 @@ export default {
v-once
:href="author.webUrl"
class="js-user-link"
- :data-user-id="author.id"
+ :data-user-id="authorId"
:data-username="author.username"
>
<span class="note-header-author-name gl-font-weight-bold">{{ author.name }}</span>
diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue
index da492f03801..013dd1d89f3 100644
--- a/app/assets/javascripts/design_management/components/design_todo_button.vue
+++ b/app/assets/javascripts/design_management/components/design_todo_button.vue
@@ -1,6 +1,6 @@
<script>
import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
-import TodoButton from '~/vue_shared/components/todo_button.vue';
+import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql';
import getDesignQuery from '../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../mixins/all_versions';
@@ -60,22 +60,6 @@ export default {
},
},
methods: {
- updateGlobalTodoCount(additionalTodoCount) {
- const currentCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
- const todoToggleEvent = new CustomEvent('todo:toggle', {
- detail: {
- count: Math.max(currentCount + additionalTodoCount, 0),
- },
- });
-
- document.dispatchEvent(todoToggleEvent);
- },
- incrementGlobalTodoCount() {
- this.updateGlobalTodoCount(1);
- },
- decrementGlobalTodoCount() {
- this.updateGlobalTodoCount(-1);
- },
createTodo() {
this.todoLoading = true;
return this.$apollo
@@ -92,9 +76,6 @@ export default {
}
},
})
- .then(() => {
- this.incrementGlobalTodoCount();
- })
.catch((err) => {
this.$emit('error', Error(CREATE_DESIGN_TODO_ERROR));
throw err;
@@ -130,9 +111,6 @@ export default {
}
},
})
- .then(() => {
- this.decrementGlobalTodoCount();
- })
.catch((err) => {
this.$emit('error', Error(DELETE_DESIGN_TODO_ERROR));
throw err;
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index ad78433c7ce..19bfa123487 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -41,7 +41,7 @@ import {
TOGGLE_TODO_ERROR,
designDeletionError,
} from '../../utils/error_messages';
-import { trackDesignDetailView, usagePingDesignDetailView } from '../../utils/tracking';
+import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking';
const DEFAULT_SCALE = 1;
@@ -292,7 +292,7 @@ export default {
);
if (this.glFeatures.usageDataDesignAction) {
- usagePingDesignDetailView();
+ servicePingDesignDetailView();
}
},
updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) {
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
index 905134fa985..23aec46c152 100644
--- a/app/assets/javascripts/design_management/utils/tracking.js
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -14,7 +14,7 @@ export const DESIGN_SNOWPLOW_EVENT_TYPES = {
UPDATE_DESIGN: 'update_design',
};
-export const DESIGN_USAGE_PING_EVENT_TYPES = {
+export const DESIGN_SERVICE_PING_EVENT_TYPES = {
DESIGN_ACTION: 'design_action',
};
@@ -52,8 +52,8 @@ export function trackDesignUpdate() {
}
/**
- * Track "design detail" view via usage ping
+ * Track "design detail" view via service ping
*/
-export function usagePingDesignDetailView() {
- Api.trackRedisHllUserEvent(DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION);
+export function servicePingDesignDetailView() {
+ Api.trackRedisHllUserEvent(DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION);
}
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 7200e6c2e3a..14d6e2db09d 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import FilesCommentButton from './files_comment_button';
@@ -77,7 +77,11 @@ export default class Diff {
axios
.get(link, { params })
.then(({ data }) => $target.parent().replaceWith(data))
- .catch(() => flash(__('An error occurred while loading diff')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while loading diff'),
+ }),
+ );
}
openAnchoredDiff(cb) {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 61946d345e3..e33b60f8ab5 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -14,7 +14,7 @@ import {
} from '~/behaviors/shortcuts/keybindings';
import createFlash from '~/flash';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
@@ -42,6 +42,7 @@ import {
TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
+import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
import { fileByFile } from '../utils/preferences';
@@ -52,7 +53,9 @@ import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import MergeConflictWarning from './merge_conflict_warning.vue';
import NoChanges from './no_changes.vue';
+import PreRenderer from './pre_renderer.vue';
import TreeList from './tree_list.vue';
+import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
export default {
name: 'DiffsApp',
@@ -71,6 +74,8 @@ export default {
GlSprintf,
DynamicScroller,
DynamicScrollerItem,
+ PreRenderer,
+ VirtualScrollerScrollSync,
},
alerts: {
ALERT_OVERFLOW_HIDDEN,
@@ -166,6 +171,8 @@ export default {
return {
treeWidth,
diffFilesLength: 0,
+ virtualScrollCurrentIndex: -1,
+ disableVirtualScroller: false,
};
},
computed: {
@@ -186,6 +193,7 @@ export default {
'showTreeList',
'isLoading',
'startVersion',
+ 'latestDiff',
'currentDiffFileId',
'isTreeLoaded',
'conflictResolutionPath',
@@ -228,8 +236,8 @@ export default {
isLimitedContainer() {
return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout;
},
- isDiffHead() {
- return parseBoolean(getParameterByName('diff_head'));
+ isFullChangeset() {
+ return this.startVersion === null && this.latestDiff;
},
showFileByFileNavigation() {
return this.diffFiles.length > 1 && this.viewDiffsFileByFile;
@@ -252,7 +260,7 @@ export default {
if (this.renderOverflowWarning) {
visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN;
- } else if (this.isDiffHead && this.hasConflicts) {
+ } else if (this.isFullChangeset && this.hasConflicts) {
visible = this.$options.alerts.ALERT_MERGE_CONFLICT;
} else if (this.whichCollapsedTypes.automatic && !this.viewDiffsFileByFile) {
visible = this.$options.alerts.ALERT_COLLAPSED_FILES;
@@ -323,6 +331,11 @@ export default {
this.setHighlightedRow(id.split('diff-content').pop().slice(1));
}
+ if (window.gon?.features?.diffsVirtualScrolling) {
+ diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
+ diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex);
+ }
+
if (window.gon?.features?.diffSettingsUsageData) {
if (this.renderTreeList) {
api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE);
@@ -377,6 +390,11 @@ export default {
diffsApp.deinstrument();
this.unsubscribeFromEvents();
this.removeEventListeners();
+
+ if (window.gon?.features?.diffsVirtualScrolling) {
+ diffsEventHub.$off('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
+ diffsEventHub.$off('scrollToIndex', this.scrollVirtualScrollerToIndex);
+ }
},
methods: {
...mapActions(['startTaskList']),
@@ -458,7 +476,11 @@ export default {
},
setDiscussions() {
requestIdleCallback(
- () => this.assignDiscussionsToDiff().then(this.$nextTick).then(this.startTaskList),
+ () =>
+ this.assignDiscussionsToDiff()
+ .then(this.$nextTick)
+ .then(this.startTaskList)
+ .then(this.scrollVirtualScrollerToDiffNote),
{ timeout: 1000 },
);
},
@@ -483,12 +505,17 @@ export default {
this.moveToNeighboringCommit({ direction: 'previous' }),
);
}
+
+ Mousetrap.bind(['ctrl+f', 'command+f'], () => {
+ this.disableVirtualScroller = true;
+ });
},
removeEventListeners() {
Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF));
Mousetrap.unbind(keysFor(MR_NEXT_FILE_IN_DIFF));
Mousetrap.unbind(keysFor(MR_COMMITS_NEXT_COMMIT));
Mousetrap.unbind(keysFor(MR_COMMITS_PREVIOUS_COMMIT));
+ Mousetrap.unbind(['ctrl+f', 'command+f']);
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
@@ -508,6 +535,36 @@ export default {
return this.setShowTreeList({ showTreeList, saving: false });
},
+ async scrollVirtualScrollerToFileHash(hash) {
+ const index = this.diffFiles.findIndex((f) => f.file_hash === hash);
+
+ if (index !== -1) {
+ this.scrollVirtualScrollerToIndex(index);
+ }
+ },
+ async scrollVirtualScrollerToIndex(index) {
+ this.virtualScrollCurrentIndex = index;
+
+ await this.$nextTick();
+
+ this.virtualScrollCurrentIndex = -1;
+ },
+ scrollVirtualScrollerToDiffNote() {
+ if (!window.gon?.features?.diffsVirtualScrolling) return;
+
+ const id = window?.location?.hash;
+
+ if (id.startsWith('#note_')) {
+ const noteId = id.replace('#note_', '');
+ const discussion = this.$store.state.notes.discussions.find(
+ (d) => d.diff_file && d.notes.find((n) => n.id === noteId),
+ );
+
+ if (discussion) {
+ this.scrollVirtualScrollerToFileHash(discussion.diff_file.file_hash);
+ }
+ }
+ },
},
minTreeWidth: MIN_TREE_WIDTH,
maxTreeWidth: MAX_TREE_WIDTH,
@@ -571,7 +628,8 @@ export default {
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
<dynamic-scroller
- v-if="isVirtualScrollingEnabled"
+ v-if="!disableVirtualScroller && isVirtualScrollingEnabled"
+ ref="virtualScroller"
:items="diffs"
:min-item-size="70"
:buffer="1000"
@@ -579,7 +637,7 @@ export default {
page-mode
>
<template #default="{ item, index, active }">
- <dynamic-scroller-item :item="item" :active="active">
+ <dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<diff-file
:file="item"
:reviewed="fileReviews[item.id]"
@@ -588,9 +646,29 @@ export default {
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
+ :active="active"
/>
</dynamic-scroller-item>
</template>
+ <template #before>
+ <pre-renderer :max-length="diffFilesLength">
+ <template #default="{ item, index, active }">
+ <dynamic-scroller-item :item="item" :active="active">
+ <diff-file
+ :file="item"
+ :reviewed="fileReviews[item.id]"
+ :is-first-file="index === 0"
+ :is-last-file="index === diffFilesLength - 1"
+ :help-page-path="helpPagePath"
+ :can-current-user-fork="canCurrentUserFork"
+ :view-diffs-file-by-file="viewDiffsFileByFile"
+ pre-render
+ />
+ </dynamic-scroller-item>
+ </template>
+ </pre-renderer>
+ <virtual-scroller-scroll-sync :index="virtualScrollCurrentIndex" />
+ </template>
</dynamic-scroller>
<template v-else>
<diff-file
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index 0cf1cdb17f8..240f102e600 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlButton } from '@gitlab/ui';
+import { mapState } from 'vuex';
import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
@@ -27,11 +28,15 @@ export default {
};
},
computed: {
+ ...mapState('diffs', ['diffFiles']),
containerClasses() {
return {
[CENTERED_LIMITED_CONTAINER_CLASSES]: this.limited,
};
},
+ shouldDisplay() {
+ return !this.isDismissed && this.diffFiles.length > 1;
+ },
},
methods: {
@@ -48,7 +53,7 @@ export default {
</script>
<template>
- <div v-if="!isDismissed" data-testid="root" :class="containerClasses" class="col-12">
+ <div v-if="shouldDisplay" data-testid="root" :class="containerClasses" class="col-12">
<gl-alert
:dismissible="true"
:title="__('Some changes are not shown')"
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index e2a1f7236c5..f098d20afd1 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -99,7 +99,7 @@ export default {
v-gl-tooltip.hover
variant="default"
icon="file-tree"
- class="gl-mr-3 js-toggle-tree-list"
+ class="gl-mr-3 js-toggle-tree-list btn-icon"
:title="toggleFileBrowserTitle"
:aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
@@ -109,7 +109,7 @@ export default {
{{ __('Viewing commit') }}
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
</div>
- <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3">
+ <div v-if="hasNeighborCommits" class="commit-nav-buttons">
<gl-button-group>
<gl-button
:href="previousCommitUrl"
@@ -160,6 +160,15 @@ export default {
/>
</template>
</gl-sprintf>
+ <gl-button
+ v-if="commit || startVersion"
+ :href="latestVersionPath"
+ variant="default"
+ class="js-latest-version"
+ :class="{ 'gl-ml-3': commit && !hasNeighborCommits }"
+ >
+ {{ __('Show latest version') }}
+ </gl-button>
<div v-if="hasChanges" class="inline-parallel-buttons d-none d-md-flex ml-auto">
<diff-stats
:diff-files-count-text="diffFilesCountText"
@@ -167,14 +176,6 @@ export default {
:removed-lines="removedLines"
/>
<gl-button
- v-if="commit || startVersion"
- :href="latestVersionPath"
- variant="default"
- class="gl-mr-3 js-latest-version"
- >
- {{ __('Show latest version') }}
- </gl-button>
- <gl-button
v-show="whichCollapsedTypes.any"
variant="default"
class="gl-mr-3"
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index cb74c7dc7cd..858d9e221ae 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { mapInline, mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils';
+import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils';
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { diffViewerModes } from '~/ide/constants';
@@ -9,7 +9,6 @@ import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue';
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NoteForm from '../../notes/components/note_form.vue';
import eventHub from '../../notes/event_hub';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -18,14 +17,10 @@ import { getDiffMode } from '../store/utils';
import DiffDiscussions from './diff_discussions.vue';
import DiffView from './diff_view.vue';
import ImageDiffOverlay from './image_diff_overlay.vue';
-import InlineDiffView from './inline_diff_view.vue';
-import ParallelDiffView from './parallel_diff_view.vue';
export default {
components: {
GlLoadingIcon,
- InlineDiffView,
- ParallelDiffView,
DiffView,
DiffViewer,
NoteForm,
@@ -36,7 +31,7 @@ export default {
userAvatarLink,
DiffFileDrafts,
},
- mixins: [diffLineNoteFormMixin, draftCommentsMixin, glFeatureFlagsMixin()],
+ mixins: [diffLineNoteFormMixin, draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -52,7 +47,6 @@ export default {
...mapState('diffs', ['projectPath']),
...mapGetters('diffs', [
'isInlineView',
- 'isParallelView',
'getCommentFormForDiffFile',
'diffLines',
'fileLineCodequality',
@@ -86,15 +80,8 @@ export default {
return this.getUserData;
},
mappedLines() {
- if (this.glFeatures.unifiedDiffComponents) {
- return this.diffLines(this.diffFile, true).map(mapParallel(this)) || [];
- }
-
- // TODO: Everything below this line can be deleted when unifiedDiffComponents FF is removed
- if (this.isInlineView) {
- return this.diffFile.highlighted_diff_lines.map(mapInline(this));
- }
- return this.diffLines(this.diffFile).map(mapParallel(this));
+ // TODO: Do this data generation when we recieve a response to save a computed property being created
+ return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
},
},
updated() {
@@ -126,7 +113,7 @@ export default {
<template>
<div class="diff-content">
<div class="diff-viewer">
- <template v-if="isTextFile && glFeatures.unifiedDiffComponents">
+ <template v-if="isTextFile">
<diff-view
:diff-file="diffFile"
:diff-lines="mappedLines"
@@ -135,21 +122,6 @@ export default {
/>
<gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
</template>
- <template v-else-if="isTextFile">
- <inline-diff-view
- v-if="isInlineView"
- :diff-file="diffFile"
- :diff-lines="mappedLines"
- :help-page-path="helpPagePath"
- />
- <parallel-diff-view
- v-else-if="isParallelView"
- :diff-file="diffFile"
- :diff-lines="mappedLines"
- :help-page-path="helpPagePath"
- />
- <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
- </template>
<not-diffable-viewer v-else-if="notDiffable" />
<no-preview-viewer v-else-if="noPreview" />
<diff-viewer
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index ed8455f0c1c..dde5ea81e9a 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -2,6 +2,7 @@
import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { IdState } from 'vendor/vue-virtual-scroller';
import createFlash from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
@@ -19,7 +20,7 @@ import {
} from '../constants';
import eventHub from '../event_hub';
import { DIFF_FILE, GENERIC_ERROR } from '../i18n';
-import { collapsedType, isCollapsed, getShortShaFromFile } from '../utils/diff_file';
+import { collapsedType, getShortShaFromFile } from '../utils/diff_file';
import DiffContent from './diff_content.vue';
import DiffFileHeader from './diff_file_header.vue';
@@ -34,7 +35,7 @@ export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
+ mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })],
props: {
file: {
type: Object,
@@ -68,12 +69,22 @@ export default {
type: Boolean,
required: true,
},
+ active: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ preRender: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- data() {
+ idState() {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
- isCollapsed: isCollapsed(this.file),
+ hasToggled: false,
};
},
i18n: {
@@ -91,7 +102,7 @@ export default {
return getShortShaFromFile(this.file);
},
showLoadingIcon() {
- return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
+ return this.idState.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
},
hasDiff() {
return hasDiff(this.file);
@@ -152,45 +163,39 @@ export default {
codequalityDiffForFile() {
return this.codequalityDiff?.files?.[this.file.file_path] || [];
},
+ isCollapsed() {
+ if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) {
+ return this.viewDiffsFileByFile ? false : this.file.viewer?.automaticallyCollapsed;
+ }
+
+ return this.file.viewer?.manuallyCollapsed;
+ },
},
watch: {
'file.id': {
handler: function fileIdHandler() {
+ if (this.preRender) return;
+
this.manageViewedEffects();
},
},
'file.file_hash': {
handler: function hashChangeWatch(newHash, oldHash) {
- this.isCollapsed = isCollapsed(this.file);
-
- if (newHash && oldHash && !this.hasDiff) {
+ if (newHash && oldHash && !this.hasDiff && !this.preRender) {
this.requestDiff();
}
},
- immediate: true,
- },
- 'file.viewer.automaticallyCollapsed': {
- handler: function autoChangeWatch(automaticValue) {
- if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) {
- this.isCollapsed = this.viewDiffsFileByFile ? false : automaticValue;
- }
- },
- immediate: true,
- },
- 'file.viewer.manuallyCollapsed': {
- handler: function manualChangeWatch(manualValue) {
- if (manualValue !== null) {
- this.isCollapsed = manualValue;
- }
- },
- immediate: true,
},
},
created() {
+ if (this.preRender) return;
+
notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff);
eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
mounted() {
+ if (this.preRender) return;
+
if (this.hasDiff) {
this.postRender();
}
@@ -198,6 +203,8 @@ export default {
this.manageViewedEffects();
},
beforeDestroy() {
+ if (this.preRender) return;
+
eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener);
},
methods: {
@@ -208,8 +215,14 @@ export default {
'setFileCollapsedByUser',
]),
manageViewedEffects() {
- if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) {
+ if (
+ !this.idState.hasToggled &&
+ this.reviewed &&
+ !this.isCollapsed &&
+ this.showLocalFileReviews
+ ) {
this.handleToggle();
+ this.idState.hasToggled = true;
}
},
expandAllListener() {
@@ -252,11 +265,11 @@ export default {
}
},
requestDiff() {
- this.isLoadingCollapsedDiff = true;
+ this.idState.isLoadingCollapsedDiff = true;
this.loadCollapsedDiff(this.file)
.then(() => {
- this.isLoadingCollapsedDiff = false;
+ this.idState.isLoadingCollapsedDiff = false;
this.setRenderIt(this.file);
})
.then(() => {
@@ -269,17 +282,17 @@ export default {
);
})
.catch(() => {
- this.isLoadingCollapsedDiff = false;
+ this.idState.isLoadingCollapsedDiff = false;
createFlash({
message: this.$options.i18n.genericError,
});
});
},
showForkMessage() {
- this.forkMessageVisible = true;
+ this.idState.forkMessageVisible = true;
},
hideForkMessage() {
- this.forkMessageVisible = false;
+ this.idState.forkMessageVisible = false;
},
},
};
@@ -287,7 +300,7 @@ export default {
<template>
<div
- :id="file.file_hash"
+ :id="!preRender && active && file.file_hash"
:class="{
'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
@@ -313,7 +326,10 @@ export default {
@showForkMessage="showForkMessage"
/>
- <div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion">
+ <div
+ v-if="idState.forkMessageVisible"
+ class="js-file-fork-suggestion-section file-fork-suggestion"
+ >
<span v-safe-html="forkMessage" class="file-fork-suggestion-note"></span>
<a
:href="file.fork_path"
@@ -330,12 +346,13 @@ export default {
</div>
<template v-else>
<div
- :id="`diff-content-${file.file_hash}`"
+ :id="!preRender && active && `diff-content-${file.file_hash}`"
:class="hasBodyClasses.contentByHash"
data-testid="content-area"
>
<gl-loading-icon
v-if="showLoadingIcon"
+ size="sm"
class="diff-content loading gl-my-0 gl-pt-3"
data-testid="loader-icon"
/>
@@ -357,7 +374,7 @@ export default {
</div>
<template v-else>
<div
- v-show="showWarning"
+ v-if="showWarning"
class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
>
<p class="gl-mb-5">
@@ -373,7 +390,7 @@ export default {
</gl-button>
</div>
<diff-content
- v-show="showContent"
+ v-if="showContent"
:class="hasBodyClasses.content"
:diff-file="file"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 45c7fe35f03..667b8745f7b 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -13,6 +13,7 @@ import {
} from '@gitlab/ui';
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { IdState } from 'vendor/vue-virtual-scroller';
import { diffViewerModes } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -41,13 +42,12 @@ export default {
GlDropdownDivider,
GlFormCheckbox,
GlLoadingIcon,
- CodeQualityBadge: () => import('ee_component/diffs/components/code_quality_badge.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [glFeatureFlagsMixin()],
+ mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.diffFile.file_hash })],
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: s__('Compare submodule commit revisions'),
@@ -102,7 +102,7 @@ export default {
default: () => [],
},
},
- data() {
+ idState() {
return {
moreActionsShown: false,
};
@@ -202,8 +202,18 @@ export default {
externalUrlLabel() {
return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
},
- showCodequalityBadge() {
- return this.codequalityDiff?.length > 0 && !this.glFeatures.codequalityMrDiffAnnotations;
+ },
+ watch: {
+ 'idState.moreActionsShown': {
+ handler(val) {
+ const el = this.$el.closest('.vue-recycle-scroller__item-view');
+
+ if (this.glFeatures.diffsVirtualScrolling && el) {
+ // We can't add a style with Vue because of the way the virtual
+ // scroller library renders the diff files
+ el.style.zIndex = val ? '1' : null;
+ }
+ },
},
},
methods: {
@@ -239,7 +249,7 @@ export default {
}
},
setMoreActionsShown(val) {
- this.moreActionsShown = val;
+ this.idState.moreActionsShown = val;
},
toggleReview(newReviewedStatus) {
const autoCollapsed =
@@ -268,7 +278,7 @@ export default {
<template>
<div
ref="header"
- :class="{ 'gl-z-dropdown-menu!': moreActionsShown }"
+ :class="{ 'gl-z-dropdown-menu!': idState.moreActionsShown }"
class="js-file-title file-title file-title-flex-parent"
data-qa-selector="file_title_container"
:data-qa-file-name="filePath"
@@ -292,7 +302,7 @@ export default {
>
<file-icon
:file-name="filePath"
- :size="18"
+ :size="16"
aria-hidden="true"
css-classes="gl-mr-2"
:submodule="diffFile.submodule"
@@ -336,13 +346,6 @@ export default {
data-track-property="diff_copy_file"
/>
- <code-quality-badge
- v-if="showCodequalityBadge"
- :file-name="filePath"
- :codequality-diff="codequalityDiff"
- class="gl-mr-2"
- />
-
<small v-if="isModeChanged" ref="fileMode" class="mr-1">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -453,7 +456,7 @@ export default {
:disabled="diffFile.isLoadingFullFile"
@click="toggleFullDiff(diffFile.file_path)"
>
- <gl-loading-icon v-if="diffFile.isLoadingFullFile" inline />
+ <gl-loading-icon v-if="diffFile.isLoadingFullFile" size="sm" inline />
{{ expandDiffToFullFileTitle }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index c907b5dffaf..c445989f143 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -106,10 +106,7 @@ export default {
};
const getDiffLines = () => {
if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
- return this.diffLines(this.diffFile, this.glFeatures.unifiedDiffComponents).reduce(
- combineSides,
- [],
- );
+ return this.diffLines(this.diffFile).reduce(combineSides, []);
}
return this.diffFile[INLINE_DIFF_LINES_KEY];
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 37dd7941b2e..c310bd9f31a 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,13 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { memoize } from 'lodash';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import {
- CONTEXT_LINE_CLASS_NAME,
PARALLEL_DIFF_VIEW_TYPE,
- CONFLICT_MARKER_OUR,
CONFLICT_MARKER_THEIR,
CONFLICT_OUR,
CONFLICT_THEIR,
@@ -22,15 +18,8 @@ import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
export default {
- components: {
- DiffGutterAvatars,
- CodeQualityGutterIcon: () =>
- import('ee_component/diffs/components/code_quality_gutter_icon.vue'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [glFeatureFlagsMixin()],
+ DiffGutterAvatars,
+ CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'),
props: {
fileHash: {
type: String,
@@ -58,148 +47,109 @@ export default {
type: Number,
required: true,
},
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ fileLineCoverage: {
+ type: Function,
+ required: true,
+ },
},
- data() {
- return {
- dragging: false,
- };
- },
- computed: {
- ...mapGetters('diffs', ['fileLineCoverage']),
- ...mapGetters(['isLoggedIn']),
- ...mapState({
- isHighlighted(state) {
- const line = this.line.left?.line_code ? this.line.left : this.line.right;
- return utils.isHighlighted(state, line, false);
- },
- }),
- classNameMap() {
+ classNameMap: memoize(
+ (props) => {
return {
- [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft,
- [PARALLEL_DIFF_VIEW_TYPE]: !this.inline,
- commented: this.isCommented,
+ [PARALLEL_DIFF_VIEW_TYPE]: !props.inline,
+ commented: props.isCommented,
};
},
- parallelViewLeftLineType() {
- return utils.parallelViewLeftLineType(this.line, this.isHighlighted || this.isCommented);
+ (props) => [!props.inline, props.isCommented].join(':'),
+ ),
+ parallelViewLeftLineType: memoize(
+ (props) => {
+ return utils.parallelViewLeftLineType(props.line, props.isHighlighted || props.isCommented);
},
- coverageStateLeft() {
- if (!this.inline || !this.line.left) return {};
- return this.fileLineCoverage(this.filePath, this.line.left.new_line);
+ (props) =>
+ [props.line.left?.type, props.line.right?.type, props.isHighlighted, props.isCommented].join(
+ ':',
+ ),
+ ),
+ coverageStateLeft: memoize(
+ (props) => {
+ if (!props.inline || !props.line.left) return {};
+ return props.fileLineCoverage(props.filePath, props.line.left.new_line);
},
- coverageStateRight() {
- if (!this.line.right) return {};
- return this.fileLineCoverage(this.filePath, this.line.right.new_line);
+ (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'),
+ ),
+ coverageStateRight: memoize(
+ (props) => {
+ if (!props.line.right) return {};
+ return props.fileLineCoverage(props.filePath, props.line.right.new_line);
},
- showCodequalityLeft() {
- return (
- this.glFeatures.codequalityMrDiffAnnotations &&
- this.inline &&
- this.line.left?.codequality?.length > 0
- );
+ (props) => [props.line.right?.new_line, props.filePath].join(':'),
+ ),
+ showCodequalityLeft: memoize(
+ (props) => {
+ return props.inline && props.line.left?.codequality?.length > 0;
},
- showCodequalityRight() {
- return (
- this.glFeatures.codequalityMrDiffAnnotations &&
- !this.inline &&
- this.line.right?.codequality?.length > 0
- );
+ (props) => [props.inline, props.line.left?.codequality?.length].join(':'),
+ ),
+ showCodequalityRight: memoize(
+ (props) => {
+ return !props.inline && props.line.right?.codequality?.length > 0;
},
- classNameMapCellLeft() {
+ (props) => [props.inline, props.line.right?.codequality?.length].join(':'),
+ ),
+ classNameMapCellLeft: memoize(
+ (props) => {
return utils.classNameMapCell({
- line: this.line.left,
- hll: this.isHighlighted || this.isCommented,
- isLoggedIn: this.isLoggedIn,
+ line: props.line.left,
+ hll: props.isHighlighted || props.isCommented,
});
},
- classNameMapCellRight() {
+ (props) => [props.line.left.type, props.isHighlighted, props.isCommented].join(':'),
+ ),
+ classNameMapCellRight: memoize(
+ (props) => {
return utils.classNameMapCell({
- line: this.line.right,
- hll: this.isHighlighted || this.isCommented,
- isLoggedIn: this.isLoggedIn,
+ line: props.line.right,
+ hll: props.isHighlighted || props.isCommented,
});
},
- addCommentTooltipLeft() {
- return utils.addCommentTooltip(this.line.left, this.glFeatures.dragCommentSelection);
- },
- addCommentTooltipRight() {
- return utils.addCommentTooltip(this.line.right, this.glFeatures.dragCommentSelection);
+ (props) => [props.line.right.type, props.isHighlighted, props.isCommented].join(':'),
+ ),
+ shouldRenderCommentButton: memoize(
+ (props) => {
+ return isLoggedIn() && !props.line.isContextLineLeft && !props.line.isMetaLineLeft;
},
- emptyCellRightClassMap() {
- return { conflict_their: this.line.left?.type === CONFLICT_OUR };
- },
- emptyCellLeftClassMap() {
- return { conflict_our: this.line.right?.type === CONFLICT_THEIR };
- },
- shouldRenderCommentButton() {
- return this.isLoggedIn && !this.line.isContextLineLeft && !this.line.isMetaLineLeft;
- },
- isLeftConflictMarker() {
- return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type);
- },
- interopLeftAttributes() {
- if (this.inline) {
- return getInteropInlineAttributes(this.line.left);
- }
+ (props) => [props.line.isContextLineLeft, props.line.isMetaLineLeft].join(':'),
+ ),
+ interopLeftAttributes(props) {
+ if (props.inline) {
+ return getInteropInlineAttributes(props.line.left);
+ }
- return getInteropOldSideAttributes(this.line.left);
- },
- interopRightAttributes() {
- return getInteropNewSideAttributes(this.line.right);
- },
+ return getInteropOldSideAttributes(props.line.left);
},
- mounted() {
- this.scrollToLineIfNeededParallel(this.line);
+ interopRightAttributes(props) {
+ return getInteropNewSideAttributes(props.line.right);
},
- methods: {
- ...mapActions('diffs', [
- 'scrollToLineIfNeededParallel',
- 'showCommentForm',
- 'setHighlightedRow',
- 'toggleLineDiscussions',
- ]),
- // Prevent text selecting on both sides of parallel diff view
- // Backport of the same code from legacy diff notes.
- handleParallelLineMouseDown(e) {
- const line = e.currentTarget;
- const table = line.closest('.diff-table');
-
- table.classList.remove('left-side-selected', 'right-side-selected');
- const [lineClass] = ['left-side', 'right-side'].filter((name) =>
- line.classList.contains(name),
- );
-
- if (lineClass) {
- table.classList.add(`${lineClass}-selected`);
- }
- },
- handleCommentButton(line) {
- this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash });
- },
- conflictText(line) {
- return line.type === CONFLICT_MARKER_THEIR
- ? this.$options.THEIR_CHANGES
- : this.$options.OUR_CHANGES;
- },
- onDragEnd() {
- this.dragging = false;
- if (!this.glFeatures.dragCommentSelection) return;
-
- this.$emit('stopdragging');
+ conflictText: memoize(
+ (line) => {
+ return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes';
},
- onDragEnter(line, index) {
- if (!this.glFeatures.dragCommentSelection) return;
+ (line) => line.type,
+ ),
+ lineContent: memoize(
+ (line) => {
+ if (line.isConflictMarker) {
+ return line.type === CONFLICT_MARKER_THEIR ? 'HEAD//our changes' : 'origin//their changes';
+ }
- this.$emit('enterdragging', { ...line, index });
- },
- onDragStart(line) {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- this.dragging = true;
- this.$emit('startdragging', line);
+ return line.rich_text;
},
- },
- OUR_CHANGES: 'HEAD//our changes',
- THEIR_CHANGES: 'origin//their changes',
+ (line) => line.line_code,
+ ),
CONFLICT_MARKER,
CONFLICT_MARKER_THEIR,
CONFLICT_OUR,
@@ -207,250 +157,256 @@ export default {
};
</script>
-<template>
- <div :class="classNameMap" class="diff-grid-row diff-tr line_holder">
+<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
+<template functional>
+ <div :class="$options.classNameMap(props)" class="diff-grid-row diff-tr line_holder">
<div
+ :id="props.line.left && props.line.left.line_code"
data-testid="left-side"
class="diff-grid-left left-side"
- v-bind="interopLeftAttributes"
+ v-bind="$options.interopLeftAttributes(props)"
@dragover.prevent
- @dragenter="onDragEnter(line.left, index)"
- @dragend="onDragEnd"
+ @dragenter="listeners.enterdragging({ ...props.line.left, index: props.index })"
+ @dragend="listeners.stopdragging"
>
- <template v-if="line.left && line.left.type !== $options.CONFLICT_MARKER">
+ <template v-if="props.line.left && props.line.left.type !== $options.CONFLICT_MARKER">
<div
- :class="classNameMapCellLeft"
+ :class="$options.classNameMapCellLeft(props)"
data-testid="left-line-number"
class="diff-td diff-line-num"
data-qa-selector="new_diff_line_link"
>
- <template v-if="!isLeftConflictMarker">
- <span
- v-if="shouldRenderCommentButton && !line.hasDiscussionsLeft"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltipLeft"
- >
- <div
- data-testid="left-comment-button"
- role="button"
- tabindex="0"
- :draggable="!line.left.commentsDisabled && glFeatures.dragCommentSelection"
- type="button"
- class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button"
- data-qa-selector="diff_comment_button"
- :class="{ 'gl-cursor-grab': dragging }"
- :disabled="line.left.commentsDisabled"
- :aria-disabled="line.left.commentsDisabled"
- @click="!line.left.commentsDisabled && handleCommentButton(line.left)"
- @keydown.enter="!line.left.commentsDisabled && handleCommentButton(line.left)"
- @keydown.space="!line.left.commentsDisabled && handleCommentButton(line.left)"
- @dragstart="!line.left.commentsDisabled && onDragStart({ ...line.left, index })"
- ></div>
- </span>
- </template>
+ <span
+ v-if="
+ !props.line.left.isConflictMarker &&
+ $options.shouldRenderCommentButton(props) &&
+ !props.line.hasDiscussionsLeft
+ "
+ class="add-diff-note tooltip-wrapper has-tooltip"
+ :title="props.line.left.addCommentTooltip"
+ >
+ <div
+ data-testid="left-comment-button"
+ role="button"
+ tabindex="0"
+ :draggable="!props.line.left.commentsDisabled"
+ type="button"
+ class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button"
+ data-qa-selector="diff_comment_button"
+ :disabled="props.line.left.commentsDisabled"
+ :aria-disabled="props.line.left.commentsDisabled"
+ @click="
+ !props.line.left.commentsDisabled &&
+ listeners.showCommentForm(props.line.left.line_code)
+ "
+ @keydown.enter="
+ !props.line.left.commentsDisabled &&
+ listeners.showCommentForm(props.line.left.line_code)
+ "
+ @keydown.space="
+ !props.line.left.commentsDisabled &&
+ listeners.showCommentForm(props.line.left.line_code)
+ "
+ @dragstart="
+ !props.line.left.commentsDisabled &&
+ listeners.startdragging({
+ event: $event,
+ line: { ...props.line.left, index: props.index },
+ })
+ "
+ ></div>
+ </span>
<a
- v-if="line.left.old_line && line.left.type !== $options.CONFLICT_THEIR"
- :data-linenumber="line.left.old_line"
- :href="line.lineHrefOld"
- @click="setHighlightedRow(line.lineCode)"
+ v-if="props.line.left.old_line && props.line.left.type !== $options.CONFLICT_THEIR"
+ :data-linenumber="props.line.left.old_line"
+ :href="props.line.lineHrefOld"
+ @click="listeners.setHighlightedRow(props.line.lineCode)"
>
</a>
- <diff-gutter-avatars
- v-if="line.hasDiscussionsLeft"
- :discussions="line.left.discussions"
- :discussions-expanded="line.left.discussionsExpanded"
+ <component
+ :is="$options.DiffGutterAvatars"
+ v-if="props.line.hasDiscussionsLeft"
+ :discussions="props.line.left.discussions"
+ :discussions-expanded="props.line.left.discussionsExpanded"
data-testid="left-discussions"
@toggleLineDiscussions="
- toggleLineDiscussions({
- lineCode: line.left.line_code,
- fileHash,
- expanded: !line.left.discussionsExpanded,
+ listeners.toggleLineDiscussions({
+ lineCode: props.line.left.line_code,
+ expanded: !props.line.left.discussionsExpanded,
})
"
/>
</div>
- <div v-if="inline" :class="classNameMapCellLeft" class="diff-td diff-line-num">
+ <div
+ v-if="props.inline"
+ :class="$options.classNameMapCellLeft(props)"
+ class="diff-td diff-line-num"
+ >
<a
- v-if="line.left.new_line && line.left.type !== $options.CONFLICT_OUR"
- :data-linenumber="line.left.new_line"
- :href="line.lineHrefOld"
- @click="setHighlightedRow(line.lineCode)"
+ v-if="props.line.left.new_line && props.line.left.type !== $options.CONFLICT_OUR"
+ :data-linenumber="props.line.left.new_line"
+ :href="props.line.lineHrefOld"
+ @click="listeners.setHighlightedRow(props.line.lineCode)"
>
</a>
</div>
<div
- v-gl-tooltip.hover
- :title="coverageStateLeft.text"
- :class="[...parallelViewLeftLineType, coverageStateLeft.class]"
- class="diff-td line-coverage left-side"
+ :title="$options.coverageStateLeft(props).text"
+ :class="[
+ $options.parallelViewLeftLineType(props),
+ $options.coverageStateLeft(props).class,
+ ]"
+ class="diff-td line-coverage left-side has-tooltip"
></div>
- <div class="diff-td line-codequality left-side" :class="[...parallelViewLeftLineType]">
- <code-quality-gutter-icon
- v-if="showCodequalityLeft"
- :file-path="filePath"
- :codequality="line.left.codequality"
+ <div
+ class="diff-td line-codequality left-side"
+ :class="$options.parallelViewLeftLineType(props)"
+ >
+ <component
+ :is="$options.CodeQualityGutterIcon"
+ v-if="$options.showCodequalityLeft(props)"
+ :codequality="props.line.left.codequality"
+ :file-path="props.filePath"
/>
</div>
<div
- :id="line.left.line_code"
- :key="line.left.line_code"
- :class="[parallelViewLeftLineType, { parallel: !inline }]"
+ :key="props.line.left.line_code"
+ :class="[
+ $options.parallelViewLeftLineType(props),
+ { parallel: !props.inline, 'gl-font-weight-bold': props.line.left.isConflictMarker },
+ ]"
class="diff-td line_content with-coverage left-side"
data-testid="left-content"
- @mousedown="handleParallelLineMouseDown"
- >
- <strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong>
- <span v-else v-html="line.left.rich_text"></span>
- </div>
+ v-html="$options.lineContent(props.line.left)"
+ ></div>
</template>
- <template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)">
- <div
- data-testid="left-empty-cell"
- class="diff-td diff-line-num old_line empty-cell"
- :class="emptyCellLeftClassMap"
- >
+ <template
+ v-else-if="
+ !props.inline || (props.line.left && props.line.left.type === $options.CONFLICT_MARKER)
+ "
+ >
+ <div data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell">
&nbsp;
</div>
- <div
- v-if="inline"
- class="diff-td diff-line-num old_line empty-cell"
- :class="emptyCellLeftClassMap"
- ></div>
- <div
- class="diff-td line-coverage left-side empty-cell"
- :class="emptyCellLeftClassMap"
- ></div>
- <div
- v-if="inline"
- class="diff-td line-codequality left-side empty-cell"
- :class="emptyCellLeftClassMap"
- ></div>
+ <div v-if="props.inline" class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td line-coverage left-side empty-cell"></div>
+ <div v-if="props.inline" class="diff-td line-codequality left-side empty-cell"></div>
<div
class="diff-td line_content with-coverage left-side empty-cell"
- :class="[emptyCellLeftClassMap, { parallel: !inline }]"
+ :class="[{ parallel: !props.inline }]"
></div>
</template>
</div>
<div
- v-if="!inline"
+ v-if="!props.inline"
+ :id="props.line.right && props.line.right.line_code"
data-testid="right-side"
class="diff-grid-right right-side"
- v-bind="interopRightAttributes"
+ v-bind="$options.interopRightAttributes(props)"
@dragover.prevent
- @dragenter="onDragEnter(line.right, index)"
- @dragend="onDragEnd"
+ @dragenter="listeners.enterdragging({ ...props.line.right, index: props.index })"
+ @dragend="listeners.stopdragging"
>
- <template v-if="line.right">
- <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
- <template v-if="line.right.type !== $options.CONFLICT_MARKER_THEIR">
+ <template v-if="props.line.right">
+ <div :class="$options.classNameMapCellRight(props)" class="diff-td diff-line-num new_line">
+ <template v-if="props.line.right.type !== $options.CONFLICT_MARKER_THEIR">
<span
- v-if="shouldRenderCommentButton && !line.hasDiscussionsRight"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltipRight"
+ v-if="$options.shouldRenderCommentButton(props) && !props.line.hasDiscussionsRight"
+ class="add-diff-note tooltip-wrapper has-tooltip"
+ :title="props.line.right.addCommentTooltip"
>
<div
data-testid="right-comment-button"
role="button"
tabindex="0"
- :draggable="!line.right.commentsDisabled && glFeatures.dragCommentSelection"
+ :draggable="!props.line.right.commentsDisabled"
type="button"
class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button"
- :class="{ 'gl-cursor-grab': dragging }"
- :disabled="line.right.commentsDisabled"
- :aria-disabled="line.right.commentsDisabled"
- @click="!line.right.commentsDisabled && handleCommentButton(line.right)"
- @keydown.enter="!line.right.commentsDisabled && handleCommentButton(line.right)"
- @keydown.space="!line.right.commentsDisabled && handleCommentButton(line.right)"
- @dragstart="!line.right.commentsDisabled && onDragStart({ ...line.right, index })"
+ :disabled="props.line.right.commentsDisabled"
+ :aria-disabled="props.line.right.commentsDisabled"
+ @click="
+ !props.line.right.commentsDisabled &&
+ listeners.showCommentForm(props.line.right.line_code)
+ "
+ @keydown.enter="
+ !props.line.right.commentsDisabled &&
+ listeners.showCommentForm(props.line.right.line_code)
+ "
+ @keydown.space="
+ !props.line.right.commentsDisabled &&
+ listeners.showCommentForm(props.line.right.line_code)
+ "
+ @dragstart="
+ !props.line.right.commentsDisabled &&
+ listeners.startdragging({
+ event: $event,
+ line: { ...props.line.right, index: props.index },
+ })
+ "
></div>
</span>
</template>
<a
- v-if="line.right.new_line"
- :data-linenumber="line.right.new_line"
- :href="line.lineHrefNew"
- @click="setHighlightedRow(line.lineCode)"
+ v-if="props.line.right.new_line"
+ :data-linenumber="props.line.right.new_line"
+ :href="props.line.lineHrefNew"
+ @click="listeners.setHighlightedRow(props.line.lineCode)"
>
</a>
- <diff-gutter-avatars
- v-if="line.hasDiscussionsRight"
- :discussions="line.right.discussions"
- :discussions-expanded="line.right.discussionsExpanded"
+ <component
+ :is="$options.DiffGutterAvatars"
+ v-if="props.line.hasDiscussionsRight"
+ :discussions="props.line.right.discussions"
+ :discussions-expanded="props.line.right.discussionsExpanded"
data-testid="right-discussions"
@toggleLineDiscussions="
- toggleLineDiscussions({
- lineCode: line.right.line_code,
- fileHash,
- expanded: !line.right.discussionsExpanded,
+ listeners.toggleLineDiscussions({
+ lineCode: props.line.right.line_code,
+ expanded: !props.line.right.discussionsExpanded,
})
"
/>
</div>
<div
- v-gl-tooltip.hover
- :title="coverageStateRight.text"
+ :title="$options.coverageStateRight(props).text"
:class="[
- line.right.type,
- coverageStateRight.class,
- { hll: isHighlighted, hll: isCommented },
+ props.line.right.type,
+ $options.coverageStateRight(props).class,
+ { hll: props.isHighlighted, hll: props.isCommented },
]"
- class="diff-td line-coverage right-side"
+ class="diff-td line-coverage right-side has-tooltip"
></div>
<div
class="diff-td line-codequality right-side"
- :class="[line.right.type, { hll: isHighlighted, hll: isCommented }]"
+ :class="[props.line.right.type, { hll: props.isHighlighted, hll: props.isCommented }]"
>
- <code-quality-gutter-icon
- v-if="showCodequalityRight"
- :file-path="filePath"
- :codequality="line.right.codequality"
+ <component
+ :is="$options.CodeQualityGutterIcon"
+ v-if="$options.showCodequalityRight(props)"
+ :codequality="props.line.right.codequality"
+ :file-path="props.filePath"
+ data-testid="codeQualityIcon"
/>
</div>
<div
- :id="line.right.line_code"
- :key="line.right.rich_text"
+ :key="props.line.right.rich_text"
:class="[
- line.right.type,
+ props.line.right.type,
{
- hll: isHighlighted,
- hll: isCommented,
- parallel: !inline,
+ hll: props.isHighlighted,
+ hll: props.isCommented,
+ 'gl-font-weight-bold': props.line.right.type === $options.CONFLICT_MARKER_THEIR,
},
]"
- class="diff-td line_content with-coverage right-side"
- @mousedown="handleParallelLineMouseDown"
- >
- <strong v-if="line.right.type === $options.CONFLICT_MARKER_THEIR">{{
- conflictText(line.right)
- }}</strong>
- <span v-else v-html="line.right.rich_text"></span>
- </div>
+ class="diff-td line_content with-coverage right-side parallel"
+ v-html="$options.lineContent(props.line.right)"
+ ></div>
</template>
<template v-else>
- <div
- data-testid="right-empty-cell"
- class="diff-td diff-line-num old_line empty-cell"
- :class="emptyCellRightClassMap"
- ></div>
- <div
- v-if="inline"
- class="diff-td diff-line-num old_line empty-cell"
- :class="emptyCellRightClassMap"
- ></div>
- <div
- class="diff-td line-coverage right-side empty-cell"
- :class="emptyCellRightClassMap"
- ></div>
- <div
- class="diff-td line-codequality right-side empty-cell"
- :class="emptyCellRightClassMap"
- ></div>
- <div
- class="diff-td line_content with-coverage right-side empty-cell"
- :class="[emptyCellRightClassMap, { parallel: !inline }]"
- ></div>
+ <div data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell"></div>
+ <div class="diff-td line-coverage right-side empty-cell"></div>
+ <div class="diff-td line-codequality right-side empty-cell"></div>
+ <div class="diff-td line_content with-coverage right-side empty-cell parallel"></div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index cd45474afcd..99999445c43 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -6,13 +6,17 @@ import {
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
EMPTY_CELL_TYPE,
+ CONFLICT_MARKER_OUR,
+ CONFLICT_MARKER_THEIR,
+ CONFLICT_THEIR,
+ CONFLICT_OUR,
} from '../constants';
-export const isHighlighted = (state, line, isCommented) => {
+export const isHighlighted = (highlightedRow, line, isCommented) => {
if (isCommented) return true;
const lineCode = line?.line_code;
- return lineCode ? lineCode === state.diffs.highlightedRow : false;
+ return lineCode ? lineCode === highlightedRow : false;
};
export const isContextLine = (type) => type === CONTEXT_LINE_TYPE;
@@ -50,13 +54,11 @@ export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => {
];
};
-export const addCommentTooltip = (line, dragCommentSelectionEnabled = false) => {
+export const addCommentTooltip = (line) => {
let tooltip;
if (!line) return tooltip;
- tooltip = dragCommentSelectionEnabled
- ? __('Add a comment to this line or drag for multiple lines')
- : __('Add a comment to this line');
+ tooltip = __('Add a comment to this line or drag for multiple lines');
const brokenSymlinks = line.commentsDisabled;
if (brokenSymlinks) {
@@ -107,6 +109,10 @@ export const mapParallel = (content) => (line) => {
hasDraft: content.hasParallelDraftLeft(content.diffFile.file_hash, line),
lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'left'),
hasCommentForm: left.hasForm,
+ isConflictMarker:
+ line.left.type === CONFLICT_MARKER_OUR || line.left.type === CONFLICT_MARKER_THEIR,
+ emptyCellClassMap: { conflict_our: line.right?.type === CONFLICT_THEIR },
+ addCommentTooltip: addCommentTooltip(line.left),
};
}
if (right) {
@@ -116,6 +122,8 @@ export const mapParallel = (content) => (line) => {
hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line),
lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'),
hasCommentForm: Boolean(right.hasForm && right.type),
+ emptyCellClassMap: { conflict_their: line.left?.type === CONFLICT_OUR },
+ addCommentTooltip: addCommentTooltip(line.right),
};
}
@@ -139,24 +147,3 @@ export const mapParallel = (content) => (line) => {
commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder',
};
};
-
-// TODO: Delete this function when unifiedDiffComponents FF is removed
-export const mapInline = (content) => (line) => {
- // Discussions/Comments
- const renderCommentRow = line.hasForm || (line.discussions?.length && line.discussionsExpanded);
-
- return {
- ...line,
- renderDiscussion: Boolean(line.discussions?.length),
- isMatchLine: isMatchLine(line.type),
- commentRowClasses: line.discussions?.length ? '' : 'js-temp-notes-holder',
- renderCommentRow,
- hasDraft: content.shouldRenderDraftRow(content.diffFile.file_hash, line),
- hasCommentForm: line.hasForm,
- isMetaLine: isMetaLine(line.type),
- isContextLine: isContextLine(line.type),
- hasDiscussions: hasDiscussions(line),
- lineHref: lineHref(line),
- lineCode: lineCode(line),
- };
-};
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index a2a6ebaeedf..5cf242b4ddd 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,12 +1,15 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
+import { IdState } from 'vendor/vue-virtual-scroller';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
+import { hide } from '~/tooltips';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DiffCommentCell from './diff_comment_cell.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
+import { isHighlighted } from './diff_row_utils';
export default {
components: {
@@ -15,7 +18,11 @@ export default {
DiffCommentCell,
DraftNote,
},
- mixins: [draftCommentsMixin, glFeatureFlagsMixin()],
+ mixins: [
+ draftCommentsMixin,
+ glFeatureFlagsMixin(),
+ IdState({ idProp: (vm) => vm.diffFile.file_hash }),
+ ],
props: {
diffFile: {
type: Object,
@@ -36,15 +43,15 @@ export default {
default: false,
},
},
- data() {
+ idState() {
return {
dragStart: null,
updatedLineRange: null,
};
},
computed: {
- ...mapGetters('diffs', ['commitId']),
- ...mapState('diffs', ['codequalityDiff']),
+ ...mapGetters('diffs', ['commitId', 'fileLineCoverage']),
+ ...mapState('diffs', ['codequalityDiff', 'highlightedRow']),
...mapState({
selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
@@ -59,45 +66,65 @@ export default {
);
},
hasCodequalityChanges() {
- return (
- this.glFeatures.codequalityMrDiffAnnotations &&
- this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0
- );
+ return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0;
},
},
methods: {
...mapActions(['setSelectedCommentPosition']),
- ...mapActions('diffs', ['showCommentForm']),
+ ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
showCommentLeft(line) {
return line.left && !line.right;
},
showCommentRight(line) {
return line.right && !line.left;
},
- onStartDragging(line) {
- this.dragStart = line;
+ onStartDragging({ event = {}, line }) {
+ if (event.target?.parentNode) {
+ hide(event.target.parentNode);
+ }
+ this.idState.dragStart = line;
},
onDragOver(line) {
- if (line.chunk !== this.dragStart.chunk) return;
+ if (line.chunk !== this.idState.dragStart.chunk) return;
- let start = this.dragStart;
+ let start = this.idState.dragStart;
let end = line;
- if (this.dragStart.index >= line.index) {
+ if (this.idState.dragStart.index >= line.index) {
start = line;
- end = this.dragStart;
+ end = this.idState.dragStart;
}
- this.updatedLineRange = { start, end };
+ this.idState.updatedLineRange = { start, end };
- this.setSelectedCommentPosition(this.updatedLineRange);
+ this.setSelectedCommentPosition(this.idState.updatedLineRange);
},
onStopDragging() {
this.showCommentForm({
- lineCode: this.updatedLineRange?.end?.line_code,
+ lineCode: this.idState.updatedLineRange?.end?.line_code,
fileHash: this.diffFile.file_hash,
});
- this.dragStart = null;
+ this.idState.dragStart = null;
+ },
+ isHighlighted(line) {
+ return isHighlighted(
+ this.highlightedRow,
+ line.left?.line_code ? line.left : line.right,
+ false,
+ );
+ },
+ handleParallelLineMouseDown(e) {
+ const line = e.target.closest('.diff-td');
+ const table = line.closest('.diff-table');
+
+ table.classList.remove('left-side-selected', 'right-side-selected');
+ const [lineClass] = ['left-side', 'right-side'].filter((name) =>
+ line.classList.contains(name),
+ );
+
+ if (lineClass) {
+ table.classList.add(`${lineClass}-selected`);
+ }
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -109,6 +136,7 @@ export default {
:class="[$options.userColorScheme, { inline, 'with-codequality': hasCodequalityChanges }]"
:data-commit-id="commitId"
class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
+ @mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">
<div
@@ -136,6 +164,14 @@ export default {
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
:inline="inline"
:index="index"
+ :is-highlighted="isHighlighted(line)"
+ :file-line-coverage="fileLineCoverage"
+ @showCommentForm="(lineCode) => showCommentForm({ lineCode, fileHash: diffFile.file_hash })"
+ @setHighlightedRow="setHighlightedRow"
+ @toggleLineDiscussions="
+ ({ lineCode, expanded }) =>
+ toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
+ "
@enterdragging="onDragOver"
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
deleted file mode 100644
index f903fef72b7..00000000000
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ /dev/null
@@ -1,204 +0,0 @@
-<script>
-import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { CONTEXT_LINE_CLASS_NAME } from '../constants';
-import { getInteropInlineAttributes } from '../utils/interoperability';
-import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import {
- isHighlighted,
- shouldShowCommentButton,
- shouldRenderCommentButton,
- classNameMapCell,
- addCommentTooltip,
-} from './diff_row_utils';
-
-export default {
- components: {
- DiffGutterAvatars,
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- SafeHtml,
- },
- props: {
- fileHash: {
- type: String,
- required: true,
- },
- filePath: {
- type: String,
- required: true,
- },
- line: {
- type: Object,
- required: true,
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- isCommented: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- isHover: false,
- };
- },
- computed: {
- ...mapGetters(['isLoggedIn']),
- ...mapGetters('diffs', ['fileLineCoverage']),
- ...mapState({
- isHighlighted(state) {
- return isHighlighted(state, this.line, this.isCommented);
- },
- }),
- classNameMap() {
- return [
- this.line.type,
- {
- [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLine,
- },
- ];
- },
- inlineRowId() {
- return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
- },
- coverageState() {
- return this.fileLineCoverage(this.filePath, this.line.new_line);
- },
- classNameMapCell() {
- return classNameMapCell({
- line: this.line,
- hll: this.isHighlighted,
- isLoggedIn: this.isLoggedIn,
- isHover: this.isHover,
- });
- },
- addCommentTooltip() {
- return addCommentTooltip(this.line);
- },
- shouldRenderCommentButton() {
- return shouldRenderCommentButton(this.isLoggedIn, true);
- },
- shouldShowCommentButton() {
- return shouldShowCommentButton(
- this.isHover,
- this.line.isContextLine,
- this.line.isMetaLine,
- this.line.hasDiscussions,
- );
- },
- shouldShowAvatarsOnGutter() {
- return this.line.hasDiscussions;
- },
- interopAttrs() {
- return getInteropInlineAttributes(this.line);
- },
- },
- mounted() {
- this.scrollToLineIfNeededInline(this.line);
- },
- methods: {
- ...mapActions('diffs', [
- 'scrollToLineIfNeededInline',
- 'showCommentForm',
- 'setHighlightedRow',
- 'toggleLineDiscussions',
- ]),
- handleMouseMove(e) {
- // To show the comment icon on the gutter we need to know if we hover the line.
- // Current table structure doesn't allow us to do this with CSS in both of the diff view types
- this.isHover = e.type === 'mouseover';
- },
- handleCommentButton() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
- },
- },
-};
-</script>
-
-<template>
- <tr
- :id="inlineRowId"
- :class="classNameMap"
- class="line_holder"
- v-bind="interopAttrs"
- @mouseover="handleMouseMove"
- @mouseout="handleMouseMove"
- >
- <td ref="oldTd" class="diff-line-num old_line" :class="classNameMapCell">
- <span
- v-if="shouldRenderCommentButton"
- ref="addNoteTooltip"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltip"
- >
- <button
- v-show="shouldShowCommentButton"
- ref="addDiffNoteButton"
- type="button"
- class="add-diff-note note-button js-add-diff-note-button"
- :disabled="line.commentsDisabled"
- :aria-label="addCommentTooltip"
- @click="handleCommentButton"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- </span>
- <a
- v-if="line.old_line"
- ref="lineNumberRefOld"
- :data-linenumber="line.old_line"
- :href="line.lineHref"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="shouldShowAvatarsOnGutter"
- :discussions="line.discussions"
- :discussions-expanded="line.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({
- lineCode: line.lineCode,
- fileHash,
- expanded: !line.discussionsExpanded,
- })
- "
- />
- </td>
- <td ref="newTd" class="diff-line-num new_line" :class="classNameMapCell">
- <a
- v-if="line.new_line"
- ref="lineNumberRefNew"
- :data-linenumber="line.new_line"
- :href="line.lineHref"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- </td>
- <td
- v-gl-tooltip.hover
- :title="coverageState.text"
- :class="[line.type, coverageState.class, { hll: isHighlighted }]"
- class="line-coverage"
- ></td>
- <td
- :key="line.line_code"
- v-safe-html="line.rich_text"
- :class="[
- line.type,
- {
- hll: isHighlighted,
- },
- ]"
- class="line_content with-coverage"
- ></td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
deleted file mode 100644
index e407609d9e9..00000000000
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ /dev/null
@@ -1,117 +0,0 @@
-<script>
-import { mapGetters, mapState } from 'vuex';
-import DraftNote from '~/batch_comments/components/draft_note.vue';
-import draftCommentsMixin from '~/diffs/mixins/draft_comments';
-import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import DiffCommentCell from './diff_comment_cell.vue';
-import DiffExpansionCell from './diff_expansion_cell.vue';
-import inlineDiffTableRow from './inline_diff_table_row.vue';
-
-export default {
- components: {
- DiffCommentCell,
- inlineDiffTableRow,
- DraftNote,
- DiffExpansionCell,
- },
- mixins: [draftCommentsMixin, glFeatureFlagsMixin()],
- props: {
- diffFile: {
- type: Object,
- required: true,
- },
- diffLines: {
- type: Array,
- required: true,
- },
- helpPagePath: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- ...mapGetters('diffs', ['commitId']),
- ...mapState({
- selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
- selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
- }),
- diffLinesLength() {
- return this.diffLines.length;
- },
- commentedLines() {
- return getCommentedLines(
- this.selectedCommentPosition || this.selectedCommentPositionHover,
- this.diffLines,
- );
- },
- },
- userColorScheme: window.gon.user_color_scheme,
-};
-</script>
-
-<template>
- <table
- :class="$options.userColorScheme"
- :data-commit-id="commitId"
- class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
- >
- <colgroup>
- <col style="width: 50px" />
- <col style="width: 50px" />
- <col style="width: 8px" />
- <col />
- </colgroup>
- <tbody>
- <template v-for="(line, index) in diffLines">
- <tr v-if="line.isMatchLine" :key="`expand-${index}`" class="line_expansion match">
- <td colspan="4" class="text-center gl-font-regular">
- <diff-expansion-cell
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
- </td>
- </tr>
- <inline-diff-table-row
- v-if="!line.isMatchLine"
- :key="`${line.line_code || index}`"
- :file-hash="diffFile.file_hash"
- :file-path="diffFile.file_path"
- :line="line"
- :is-bottom="index + 1 === diffLinesLength"
- :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
- />
- <tr
- v-if="line.renderCommentRow"
- :key="`icr-${line.line_code || index}`"
- :class="line.commentRowClasses"
- class="notes_holder"
- >
- <td class="notes-content" colspan="4">
- <diff-comment-cell
- :diff-file-hash="diffFile.file_hash"
- :line="line"
- :help-page-path="helpPagePath"
- :has-draft="line.hasDraft"
- />
- </td>
- </tr>
- <tr v-if="line.hasDraft" :key="`draft_${index}`" class="notes_holder js-temp-notes-holder">
- <td class="notes-content" colspan="4">
- <div class="content">
- <draft-note
- :draft="draftForLine(diffFile.file_hash, line)"
- :diff-file="diffFile"
- :line="line"
- />
- </div>
- </td>
- </tr>
- </template>
- </tbody>
- </table>
-</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
deleted file mode 100644
index 2d33926c8aa..00000000000
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ /dev/null
@@ -1,310 +0,0 @@
-<script>
-import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import $ from 'jquery';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
-import {
- getInteropOldSideAttributes,
- getInteropNewSideAttributes,
-} from '../utils/interoperability';
-import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import * as utils from './diff_row_utils';
-
-export default {
- components: {
- GlIcon,
- DiffGutterAvatars,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- SafeHtml,
- },
- props: {
- fileHash: {
- type: String,
- required: true,
- },
- filePath: {
- type: String,
- required: true,
- },
- line: {
- type: Object,
- required: true,
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- isCommented: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- isLeftHover: false,
- isRightHover: false,
- isCommentButtonRendered: false,
- };
- },
- computed: {
- ...mapGetters('diffs', ['fileLineCoverage']),
- ...mapGetters(['isLoggedIn']),
- ...mapState({
- isHighlighted(state) {
- const line = this.line.left?.line_code ? this.line.left : this.line.right;
- return utils.isHighlighted(state, line, this.isCommented);
- },
- }),
- classNameMap() {
- return {
- [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft,
- [PARALLEL_DIFF_VIEW_TYPE]: true,
- };
- },
- parallelViewLeftLineType() {
- return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
- },
- coverageState() {
- return this.fileLineCoverage(this.filePath, this.line.right.new_line);
- },
- classNameMapCellLeft() {
- return utils.classNameMapCell({
- line: this.line.left,
- hll: this.isHighlighted,
- isLoggedIn: this.isLoggedIn,
- isHover: this.isLeftHover,
- });
- },
- classNameMapCellRight() {
- return utils.classNameMapCell({
- line: this.line.right,
- hll: this.isHighlighted,
- isLoggedIn: this.isLoggedIn,
- isHover: this.isRightHover,
- });
- },
- addCommentTooltipLeft() {
- return utils.addCommentTooltip(this.line.left);
- },
- addCommentTooltipRight() {
- return utils.addCommentTooltip(this.line.right);
- },
- shouldRenderCommentButton() {
- return utils.shouldRenderCommentButton(this.isLoggedIn, this.isCommentButtonRendered);
- },
- shouldShowCommentButtonLeft() {
- return utils.shouldShowCommentButton(
- this.isLeftHover,
- this.line.isContextLineLeft,
- this.line.isMetaLineLeft,
- this.line.hasDiscussionsLeft,
- );
- },
- shouldShowCommentButtonRight() {
- return utils.shouldShowCommentButton(
- this.isRightHover,
- this.line.isContextLineRight,
- this.line.isMetaLineRight,
- this.line.hasDiscussionsRight,
- );
- },
- interopLeftAttributes() {
- return getInteropOldSideAttributes(this.line.left);
- },
- interopRightAttributes() {
- return getInteropNewSideAttributes(this.line.right);
- },
- },
- mounted() {
- this.scrollToLineIfNeededParallel(this.line);
- this.unwatchShouldShowCommentButton = this.$watch(
- (vm) => [vm.shouldShowCommentButtonLeft, vm.shouldShowCommentButtonRight].join(),
- (newVal) => {
- if (newVal) {
- this.isCommentButtonRendered = true;
- this.unwatchShouldShowCommentButton();
- }
- },
- );
- },
- beforeDestroy() {
- this.unwatchShouldShowCommentButton();
- },
- methods: {
- ...mapActions('diffs', [
- 'scrollToLineIfNeededParallel',
- 'showCommentForm',
- 'setHighlightedRow',
- 'toggleLineDiscussions',
- ]),
- handleMouseMove(e) {
- const isHover = e.type === 'mouseover';
- const hoveringCell = e.target.closest('td');
- const allCellsInHoveringRow = Array.from(e.currentTarget.children);
- const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
-
- if (hoverIndex >= 3) {
- this.isRightHover = isHover;
- } else {
- this.isLeftHover = isHover;
- }
- },
- // Prevent text selecting on both sides of parallel diff view
- // Backport of the same code from legacy diff notes.
- handleParallelLineMouseDown(e) {
- const line = $(e.currentTarget);
- const table = line.closest('table');
-
- table.removeClass('left-side-selected right-side-selected');
- const [lineClass] = ['left-side', 'right-side'].filter((name) => line.hasClass(name));
-
- if (lineClass) {
- table.addClass(`${lineClass}-selected`);
- }
- },
- handleCommentButton(line) {
- this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash });
- },
- },
-};
-</script>
-
-<template>
- <tr
- :class="classNameMap"
- class="line_holder"
- @mouseover="handleMouseMove"
- @mouseout="handleMouseMove"
- >
- <template v-if="line.left && !line.isMatchLineLeft">
- <td ref="oldTd" :class="classNameMapCellLeft" class="diff-line-num old_line">
- <span
- v-if="shouldRenderCommentButton"
- ref="addNoteTooltipLeft"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltipLeft"
- >
- <button
- v-show="shouldShowCommentButtonLeft"
- ref="addDiffNoteButtonLeft"
- type="button"
- class="add-diff-note note-button js-add-diff-note-button"
- :disabled="line.left.commentsDisabled"
- :aria-label="addCommentTooltipLeft"
- @click="handleCommentButton(line.left)"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- </span>
- <a
- v-if="line.left.old_line"
- ref="lineNumberRefOld"
- :data-linenumber="line.left.old_line"
- :href="line.lineHrefOld"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="line.hasDiscussionsLeft"
- :discussions="line.left.discussions"
- :discussions-expanded="line.left.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({
- lineCode: line.left.line_code,
- fileHash,
- expanded: !line.left.discussionsExpanded,
- })
- "
- />
- </td>
- <td :class="parallelViewLeftLineType" class="line-coverage left-side"></td>
- <td
- :id="line.left.line_code"
- :key="line.left.line_code"
- v-safe-html="line.left.rich_text"
- :class="parallelViewLeftLineType"
- v-bind="interopLeftAttributes"
- class="line_content with-coverage parallel left-side"
- @mousedown="handleParallelLineMouseDown"
- ></td>
- </template>
- <template v-else>
- <td class="diff-line-num old_line empty-cell"></td>
- <td class="line-coverage left-side empty-cell"></td>
- <td class="line_content with-coverage parallel left-side empty-cell"></td>
- </template>
- <template v-if="line.right && !line.isMatchLineRight">
- <td ref="newTd" :class="classNameMapCellRight" class="diff-line-num new_line">
- <span
- v-if="shouldRenderCommentButton"
- ref="addNoteTooltipRight"
- v-gl-tooltip
- class="add-diff-note tooltip-wrapper"
- :title="addCommentTooltipRight"
- >
- <button
- v-show="shouldShowCommentButtonRight"
- ref="addDiffNoteButtonRight"
- type="button"
- class="add-diff-note note-button js-add-diff-note-button"
- :disabled="line.right.commentsDisabled"
- :aria-label="addCommentTooltipRight"
- @click="handleCommentButton(line.right)"
- >
- <gl-icon :size="12" name="comment" />
- </button>
- </span>
- <a
- v-if="line.right.new_line"
- ref="lineNumberRefNew"
- :data-linenumber="line.right.new_line"
- :href="line.lineHrefNew"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="line.hasDiscussionsRight"
- :discussions="line.right.discussions"
- :discussions-expanded="line.right.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({
- lineCode: line.right.line_code,
- fileHash,
- expanded: !line.right.discussionsExpanded,
- })
- "
- />
- </td>
- <td
- v-gl-tooltip.hover
- :title="coverageState.text"
- :class="[line.right.type, coverageState.class, { hll: isHighlighted }]"
- class="line-coverage right-side"
- ></td>
- <td
- :id="line.right.line_code"
- :key="line.right.rich_text"
- v-safe-html="line.right.rich_text"
- :class="[
- line.right.type,
- {
- hll: isHighlighted,
- },
- ]"
- v-bind="interopRightAttributes"
- class="line_content with-coverage parallel right-side"
- @mousedown="handleParallelLineMouseDown"
- ></td>
- </template>
- <template v-else>
- <td class="diff-line-num old_line empty-cell"></td>
- <td class="line-coverage right-side empty-cell"></td>
- <td class="line_content with-coverage parallel right-side empty-cell"></td>
- </template>
- </tr>
-</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
deleted file mode 100644
index b167081a379..00000000000
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ /dev/null
@@ -1,142 +0,0 @@
-<script>
-import { mapGetters, mapState } from 'vuex';
-import DraftNote from '~/batch_comments/components/draft_note.vue';
-import draftCommentsMixin from '~/diffs/mixins/draft_comments';
-import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
-import DiffCommentCell from './diff_comment_cell.vue';
-import DiffExpansionCell from './diff_expansion_cell.vue';
-import parallelDiffTableRow from './parallel_diff_table_row.vue';
-
-export default {
- components: {
- DiffExpansionCell,
- parallelDiffTableRow,
- DiffCommentCell,
- DraftNote,
- },
- mixins: [draftCommentsMixin],
- props: {
- diffFile: {
- type: Object,
- required: true,
- },
- diffLines: {
- type: Array,
- required: true,
- },
- helpPagePath: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- ...mapGetters('diffs', ['commitId']),
- ...mapState({
- selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
- selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
- }),
- diffLinesLength() {
- return this.diffLines.length;
- },
- commentedLines() {
- return getCommentedLines(
- this.selectedCommentPosition || this.selectedCommentPositionHover,
- this.diffLines,
- );
- },
- },
- userColorScheme: window.gon.user_color_scheme,
-};
-</script>
-
-<template>
- <table
- :class="$options.userColorScheme"
- :data-commit-id="commitId"
- class="code diff-wrap-lines js-syntax-highlight text-file"
- >
- <colgroup>
- <col style="width: 50px" />
- <col style="width: 8px" />
- <col />
- <col style="width: 50px" />
- <col style="width: 8px" />
- <col />
- </colgroup>
- <tbody>
- <template v-for="(line, index) in diffLines">
- <tr
- v-if="line.isMatchLineLeft || line.isMatchLineRight"
- :key="`expand-${index}`"
- class="line_expansion match"
- >
- <td colspan="6" class="text-center gl-font-regular">
- <diff-expansion-cell
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line.left"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- />
- </td>
- </tr>
- <parallel-diff-table-row
- :key="line.line_code"
- :file-hash="diffFile.file_hash"
- :file-path="diffFile.file_path"
- :line="line"
- :is-bottom="index + 1 === diffLinesLength"
- :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
- />
- <tr
- v-if="line.renderCommentRow"
- :key="`dcr-${line.line_code || index}`"
- :class="line.commentRowClasses"
- class="notes_holder"
- >
- <td class="notes-content parallel old" colspan="3">
- <diff-comment-cell
- v-if="line.left"
- :line="line.left"
- :diff-file-hash="diffFile.file_hash"
- :help-page-path="helpPagePath"
- :has-draft="line.left.hasDraft"
- line-position="left"
- />
- </td>
- <td class="notes-content parallel new" colspan="3">
- <diff-comment-cell
- v-if="line.right"
- :line="line.right"
- :diff-file-hash="diffFile.file_hash"
- :line-index="index"
- :help-page-path="helpPagePath"
- :has-draft="line.right.hasDraft"
- line-position="right"
- />
- </td>
- </tr>
- <tr
- v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
- :key="`drafts-${index}`"
- :class="line.draftRowClasses"
- class="notes_holder"
- >
- <td class="notes_line old"></td>
- <td class="notes-content parallel old" colspan="2">
- <div v-if="line.left && line.left.lineDraft.isDraft" class="content">
- <draft-note :draft="line.left.lineDraft" :line="line.left" />
- </div>
- </td>
- <td class="notes_line new"></td>
- <td class="notes-content parallel new" colspan="2">
- <div v-if="line.right && line.right.lineDraft.isDraft" class="content">
- <draft-note :draft="line.right.lineDraft" :line="line.right" />
- </div>
- </td>
- </tr>
- </template>
- </tbody>
- </table>
-</template>
diff --git a/app/assets/javascripts/diffs/components/pre_renderer.vue b/app/assets/javascripts/diffs/components/pre_renderer.vue
new file mode 100644
index 00000000000..c357aa2d924
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/pre_renderer.vue
@@ -0,0 +1,84 @@
+<script>
+export default {
+ inject: ['vscrollParent'],
+ props: {
+ maxLength: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ nextIndex: -1,
+ nextItem: null,
+ startedRender: false,
+ width: 0,
+ };
+ },
+ mounted() {
+ this.width = this.$el.parentNode.offsetWidth;
+ window.test = this;
+
+ this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => {
+ await this.$nextTick();
+
+ const nextItem = this.findNextToRender();
+
+ if (nextItem) {
+ this.startedRender = true;
+ requestIdleCallback(() => {
+ this.nextItem = nextItem;
+
+ if (this.nextIndex === this.maxLength - 1) {
+ this.$nextTick(() => {
+ if (this.vscrollParent.itemsWithSize[this.maxLength - 1].size !== 0) {
+ this.clearRendering();
+ }
+ });
+ }
+ });
+ } else if (this.startedRender) {
+ this.clearRendering();
+ }
+ });
+ },
+ beforeDestroy() {
+ this.$_itemsWithSizeWatcher();
+ },
+ methods: {
+ clearRendering() {
+ this.nextItem = null;
+
+ if (this.maxLength === this.vscrollParent.itemsWithSize.length) {
+ this.$_itemsWithSizeWatcher();
+ }
+ },
+ findNextToRender() {
+ return this.vscrollParent.itemsWithSize.find(({ size }, index) => {
+ const isNext = size === 0;
+
+ if (isNext) {
+ this.nextIndex = index;
+ }
+
+ return isNext;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="nextItem" :style="{ width: `${width}px` }" class="gl-absolute diff-file-offscreen">
+ <slot
+ v-bind="{ item: nextItem.item, index: nextIndex, active: true, itemWithSize: nextItem }"
+ ></slot>
+ </div>
+</template>
+
+<style scoped>
+.diff-file-offscreen {
+ top: -200%;
+ left: -200%;
+}
+</style>
diff --git a/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
new file mode 100644
index 00000000000..984c6f8c0c9
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/virtual_scroller_scroll_sync.js
@@ -0,0 +1,51 @@
+import { handleLocationHash } from '~/lib/utils/common_utils';
+
+export default {
+ inject: ['vscrollParent'],
+ props: {
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ watch: {
+ index: {
+ handler() {
+ const { index } = this;
+
+ if (index < 0) return;
+
+ if (this.vscrollParent.itemsWithSize[index].size) {
+ this.scrollToIndex(index);
+ } else {
+ this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => {
+ await this.$nextTick();
+
+ if (this.vscrollParent.itemsWithSize[index].size) {
+ this.$_itemsWithSizeWatcher();
+ this.scrollToIndex(index);
+
+ await this.$nextTick();
+ }
+ });
+ }
+ },
+ immediate: true,
+ },
+ },
+ beforeDestroy() {
+ if (this.$_itemsWithSizeWatcher) this.$_itemsWithSizeWatcher();
+ },
+ methods: {
+ scrollToIndex(index) {
+ this.vscrollParent.scrollToItem(index);
+
+ setTimeout(() => {
+ handleLocationHash();
+ });
+ },
+ },
+ render(h) {
+ return h(null);
+ },
+};
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index d1e02fbc598..f1cf556fde0 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -59,7 +59,6 @@ export const MIN_RENDERING_MS = 2;
export const START_RENDERING_INDEX = 200;
export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines';
export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
-export const DIFFS_PER_PAGE = 20;
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 0ab72749760..ea83523008c 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -50,9 +50,6 @@ export default function initDiffsApp(store) {
click: this.openFile,
},
class: ['diff-file-finder'],
- style: {
- display: this.fileFinderVisible ? '' : 'none',
- },
});
},
});
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 2e94f147086..66510edf3db 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -25,7 +25,6 @@ import {
MIN_RENDERING_MS,
START_RENDERING_INDEX,
INLINE_DIFF_LINES_KEY,
- DIFFS_PER_PAGE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
EVT_PERF_MARK_FILE_TREE_START,
@@ -92,22 +91,18 @@ export const setBaseConfig = ({ commit }, options) => {
};
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
- const diffsGradualLoad = window.gon?.features?.diffsGradualLoad;
- let perPage = DIFFS_PER_PAGE;
+ let perPage = state.viewDiffsFileByFile ? 1 : 5;
let increaseAmount = 1.4;
-
- if (diffsGradualLoad) {
- perPage = state.viewDiffsFileByFile ? 1 : 5;
- }
-
- const startPage = diffsGradualLoad ? 0 : 1;
+ const startPage = 0;
const id = window?.location?.hash;
const isNoteLink = id.indexOf('#note') === 0;
const urlParams = {
w: state.showWhitespace ? '0' : '1',
view: 'inline',
};
+ const hash = window.location.hash.replace('#', '').split('diff-content-').pop();
let totalLoaded = 0;
+ let scrolledVirtualScroller = false;
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
@@ -122,6 +117,18 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
+ if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) {
+ const index = state.diffFiles.findIndex(
+ (f) =>
+ f.file_hash === hash || f[INLINE_DIFF_LINES_KEY].find((l) => l.line_code === hash),
+ );
+
+ if (index >= 0) {
+ eventHub.$emit('scrollToIndex', index);
+ scrolledVirtualScroller = true;
+ }
+ }
+
if (!isNoteLink && !state.currentDiffFileId) {
commit(types.VIEW_DIFF_FILE, diff_files[0].file_hash);
}
@@ -130,11 +137,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop());
}
- if (
- (diffsGradualLoad &&
- (totalLoaded === pagination.total_pages || pagination.total_pages === null)) ||
- (!diffsGradualLoad && !pagination.next_page)
- ) {
+ if (totalLoaded === pagination.total_pages || pagination.total_pages === null) {
commit(types.SET_RETRIEVING_BATCHES, false);
// We need to check that the currentDiffFileId points to a file that exists
@@ -164,15 +167,11 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return null;
}
- if (diffsGradualLoad) {
- const nextPage = page + perPage;
- perPage = Math.min(Math.ceil(perPage * increaseAmount), 30);
- increaseAmount = Math.min(increaseAmount + 0.2, 2);
-
- return nextPage;
- }
+ const nextPage = page + perPage;
+ perPage = Math.min(Math.ceil(perPage * increaseAmount), 30);
+ increaseAmount = Math.min(increaseAmount + 0.2, 2);
- return pagination.next_page;
+ return nextPage;
})
.then((nextPage) => {
dispatch('startRenderDiffsQueue');
@@ -186,7 +185,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
.catch(() => commit(types.SET_RETRIEVING_BATCHES, false));
return getBatch()
- .then(handleLocationHash)
+ .then(() => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash())
.catch(() => null);
};
@@ -250,6 +249,8 @@ export const setHighlightedRow = ({ commit }, lineCode) => {
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
commit(types.VIEW_DIFF_FILE, fileHash);
+
+ handleLocationHash();
};
// This is adding line discussions to the actual lines in the diff tree
@@ -523,9 +524,18 @@ export const scrollToFile = ({ state, commit }, path) => {
if (!state.treeEntries[path]) return;
const { fileHash } = state.treeEntries[path];
- document.location.hash = fileHash;
commit(types.VIEW_DIFF_FILE, fileHash);
+
+ if (window.gon?.features?.diffsVirtualScrolling) {
+ eventHub.$emit('scrollToFileHash', fileHash);
+
+ setTimeout(() => {
+ window.history.replaceState(null, null, `#${fileHash}`);
+ });
+ } else {
+ document.location.hash = fileHash;
+ }
};
export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => {
@@ -570,7 +580,7 @@ export const setShowWhitespace = async (
{ state, commit },
{ url, showWhitespace, updateDatabase = true },
) => {
- if (updateDatabase) {
+ if (updateDatabase && Boolean(window.gon?.current_user_id)) {
await axios.put(url || state.endpointUpdateUser, { show_whitespace_in_diffs: showWhitespace });
}
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index a536db5c417..1b6a673925f 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -151,11 +151,7 @@ export const currentDiffIndex = (state) =>
state.diffFiles.findIndex((diff) => diff.file_hash === state.currentDiffFileId),
);
-export const diffLines = (state) => (file, unifiedDiffComponents) => {
- if (!unifiedDiffComponents && state.diffViewType === INLINE_DIFF_VIEW_TYPE) {
- return null;
- }
-
+export const diffLines = (state) => (file) => {
return parallelizeDiffLines(
file.highlighted_diff_lines || [],
state.diffViewType === INLINE_DIFF_VIEW_TYPE,
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 673ec821b58..65ffd42fa27 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -1,4 +1,5 @@
-import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants';
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 75d2cf43b94..3f1af68e37a 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -381,9 +381,15 @@ function prepareDiffFileLines(file) {
}
function finalizeDiffFile(file, index) {
+ let renderIt = Boolean(window.gon?.features?.diffsVirtualScrolling);
+
+ if (!window.gon?.features?.diffsVirtualScrolling) {
+ renderIt =
+ index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false;
+ }
+
Object.assign(file, {
- renderIt:
- index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false,
+ renderIt,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index c991316dda2..849ff91841a 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -1,14 +1,15 @@
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
-export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __(
+export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __(
'"el" parameter is required for createInstance()',
);
export const URI_PREFIX = 'gitlab';
-export const CONTENT_UPDATE_DEBOUNCE = 250;
+export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
- 'Editor Lite instance is required to set up an extension.',
+ 'Source Editor instance is required to set up an extension.',
);
export const EDITOR_READY_EVENT = 'editor-ready';
diff --git a/app/assets/javascripts/editor/extensions/editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/editor_file_template_ext.js
deleted file mode 100644
index f5474318447..00000000000
--- a/app/assets/javascripts/editor/extensions/editor_file_template_ext.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Position } from 'monaco-editor';
-import { EditorLiteExtension } from './editor_lite_extension_base';
-
-export class FileTemplateExtension extends EditorLiteExtension {
- navigateFileStart() {
- this.setPosition(new Position(1, 1));
- }
-}
diff --git a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index c5ee61ec86e..410aaed86a7 100644
--- a/app/assets/javascripts/editor/extensions/editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -1,9 +1,9 @@
import Api from '~/api';
import { registerSchema } from '~/ide/utils';
import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants';
-import { EditorLiteExtension } from './editor_lite_extension_base';
+import { SourceEditorExtension } from './source_editor_extension_base';
-export class CiSchemaExtension extends EditorLiteExtension {
+export class CiSchemaExtension extends SourceEditorExtension {
/**
* Registers a syntax schema to the editor based on project
* identifier and commit.
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 05a020bd958..5fa01f03f7e 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -16,15 +16,15 @@ const createAnchor = (href) => {
return fragment;
};
-export class EditorLiteExtension {
+export class SourceEditorExtension {
constructor({ instance, ...options } = {}) {
if (instance) {
Object.assign(instance, options);
- EditorLiteExtension.highlightLines(instance);
+ SourceEditorExtension.highlightLines(instance);
if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
- EditorLiteExtension.setupLineLinking(instance);
+ SourceEditorExtension.setupLineLinking(instance);
}
- EditorLiteExtension.deferRerender(instance);
+ SourceEditorExtension.deferRerender(instance);
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
@@ -79,7 +79,7 @@ export class EditorLiteExtension {
}
static setupLineLinking(instance) {
- instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler);
+ instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler);
instance.onMouseDown((e) => {
const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
if (!isCorrectAnchor) {
diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
new file mode 100644
index 00000000000..397e090ed30
--- /dev/null
+++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js
@@ -0,0 +1,8 @@
+import { Position } from 'monaco-editor';
+import { SourceEditorExtension } from './source_editor_extension_base';
+
+export class FileTemplateExtension extends SourceEditorExtension {
+ navigateFileStart() {
+ this.setPosition(new Position(1, 1));
+ }
+}
diff --git a/app/assets/javascripts/editor/extensions/editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 2ce003753f7..997503a12f5 100644
--- a/app/assets/javascripts/editor/extensions/editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -1,6 +1,6 @@
-import { EditorLiteExtension } from './editor_lite_extension_base';
+import { SourceEditorExtension } from './source_editor_extension_base';
-export class EditorMarkdownExtension extends EditorLiteExtension {
+export class EditorMarkdownExtension extends SourceEditorExtension {
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
index 83b0386d470..98e05489c1c 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
@@ -1,7 +1,7 @@
import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
-import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
@@ -12,7 +12,7 @@ const isDiffEditorType = (instance) => {
export const UPDATE_DIMENSIONS_DELAY = 200;
-export class EditorWebIdeExtension extends EditorLiteExtension {
+export class EditorWebIdeExtension extends SourceEditorExtension {
constructor({ instance, modelManager, ...options } = {}) {
super({
instance,
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/source_editor.js
index 249888ede9b..ee97714824e 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/source_editor.js
@@ -6,23 +6,23 @@ import { registerLanguages } from '~/ide/utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { uuids } from '~/lib/utils/uuids';
import {
- EDITOR_LITE_INSTANCE_ERROR_NO_EL,
+ SOURCE_EDITOR_INSTANCE_ERROR_NO_EL,
URI_PREFIX,
EDITOR_READY_EVENT,
EDITOR_TYPE_DIFF,
} from './constants';
import { clearDomElement } from './utils';
-export default class EditorLite {
+export default class SourceEditor {
constructor(options = {}) {
this.instances = [];
this.options = {
- extraEditorClassName: 'gl-editor-lite',
+ extraEditorClassName: 'gl-source-editor',
...defaultEditorOptions,
...options,
};
- EditorLite.setupMonacoTheme();
+ SourceEditor.setupMonacoTheme();
registerLanguages(...languages);
}
@@ -56,7 +56,7 @@ export default class EditorLite {
extensionsArray.forEach((ext) => {
const prefix = ext.includes('/') ? '' : 'editor/';
const trimmedExt = ext.replace(/^\//, '').trim();
- EditorLite.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
+ SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
});
return Promise.all(promises);
@@ -77,7 +77,7 @@ export default class EditorLite {
static prepareInstance(el) {
if (!el) {
- throw new Error(EDITOR_LITE_INSTANCE_ERROR_NO_EL);
+ throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
}
clearDomElement(el);
@@ -88,7 +88,7 @@ export default class EditorLite {
}
static manageDefaultExtensions(instance, el, extensions) {
- EditorLite.loadExtensions(extensions, instance)
+ SourceEditor.loadExtensions(extensions, instance)
.then((modules) => {
if (modules) {
modules.forEach((module) => {
@@ -126,7 +126,7 @@ export default class EditorLite {
const diffModel = {
original: monacoEditor.createModel(
blobOriginalContent,
- EditorLite.getModelLanguage(model.uri.path),
+ SourceEditor.getModelLanguage(model.uri.path),
),
modified: model,
};
@@ -135,18 +135,18 @@ export default class EditorLite {
}
static convertMonacoToELInstance = (inst) => {
- const editorLiteInstanceAPI = {
+ const sourceEditorInstanceAPI = {
updateModelLanguage: (path) => {
- return EditorLite.instanceUpdateLanguage(inst, path);
+ return SourceEditor.instanceUpdateLanguage(inst, path);
},
use: (exts = []) => {
- return EditorLite.instanceApplyExtension(inst, exts);
+ return SourceEditor.instanceApplyExtension(inst, exts);
},
};
const handler = {
get(target, prop, receiver) {
- if (Reflect.has(editorLiteInstanceAPI, prop)) {
- return editorLiteInstanceAPI[prop];
+ if (Reflect.has(sourceEditorInstanceAPI, prop)) {
+ return sourceEditorInstanceAPI[prop];
}
return Reflect.get(target, prop, receiver);
},
@@ -155,7 +155,7 @@ export default class EditorLite {
};
static instanceUpdateLanguage(inst, path) {
- const lang = EditorLite.getModelLanguage(path);
+ const lang = SourceEditor.getModelLanguage(path);
const model = inst.getModel();
return monacoEditor.setModelLanguage(model, lang);
}
@@ -163,7 +163,7 @@ export default class EditorLite {
static instanceApplyExtension(inst, exts = []) {
const extensions = [].concat(exts);
extensions.forEach((extension) => {
- EditorLite.mixIntoInstance(extension, inst);
+ SourceEditor.mixIntoInstance(extension, inst);
});
return inst;
}
@@ -210,10 +210,10 @@ export default class EditorLite {
isDiff = false,
...instanceOptions
} = {}) {
- EditorLite.prepareInstance(el);
+ SourceEditor.prepareInstance(el);
const createEditorFn = isDiff ? 'createDiffEditor' : 'create';
- const instance = EditorLite.convertMonacoToELInstance(
+ const instance = SourceEditor.convertMonacoToELInstance(
monacoEditor[createEditorFn].call(this, el, {
...this.options,
...instanceOptions,
@@ -222,7 +222,7 @@ export default class EditorLite {
let model;
if (instanceOptions.model !== null) {
- model = EditorLite.createEditorModel({
+ model = SourceEditor.createEditorModel({
blobGlobalId,
blobOriginalContent,
blobPath,
@@ -233,11 +233,11 @@ export default class EditorLite {
}
instance.onDidDispose(() => {
- EditorLite.instanceRemoveFromRegistry(this, instance);
- EditorLite.instanceDisposeModels(this, instance, model);
+ SourceEditor.instanceRemoveFromRegistry(this, instance);
+ SourceEditor.instanceDisposeModels(this, instance, model);
});
- EditorLite.manageDefaultExtensions(instance, el, extensions);
+ SourceEditor.manageDefaultExtensions(instance, el, extensions);
this.instances.push(instance);
return instance;
diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue
index 539cd6963b1..4f4c32af113 100644
--- a/app/assets/javascripts/emoji/components/emoji_group.vue
+++ b/app/assets/javascripts/emoji/components/emoji_group.vue
@@ -17,6 +17,7 @@ export default {
};
</script>
+<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
<template functional>
<div class="gl-display-flex gl-flex-wrap gl-mb-2">
<template v-if="props.renderGroup">
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 217cea051b7..c642a07fd1e 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -111,7 +111,7 @@ export default {
</script>
<template>
<div class="js-deploy-board deploy-board">
- <gl-loading-icon v-if="isLoading" class="loading-icon" />
+ <gl-loading-icon v-if="isLoading" size="sm" class="loading-icon" />
<template v-else>
<div v-if="canRenderDeployBoard" class="deploy-board-information gl-p-5">
<div class="deploy-board-information gl-w-full">
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 542b8c9219d..2d98f00433a 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -80,7 +80,7 @@ export default {
<template #button-content>
<gl-icon name="play" />
<gl-icon name="chevron-down" />
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
</template>
<gl-dropdown-item
v-for="(action, i) in actions"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 4db0dff16aa..5ae8b000fc0 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -552,6 +552,9 @@ export default {
{ 'gl-display-none gl-md-display-block': !this.upcomingDeployment },
];
},
+ tableNameSpacingClass() {
+ return this.isFolder ? 'section-100' : this.tableData.name.spacing;
+ },
},
methods: {
@@ -588,8 +591,9 @@ export default {
>
<div
class="table-section section-wrap text-truncate"
- :class="tableData.name.spacing"
+ :class="tableNameSpacingClass"
role="gridcell"
+ data-testid="environment-name-cell"
>
<div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ getMobileViewTitleForField('name') }}
@@ -632,9 +636,11 @@ export default {
</div>
<div
+ v-if="!isFolder"
class="table-section deployment-column d-none d-md-block"
:class="tableData.deploy.spacing"
role="gridcell"
+ data-testid="enviornment-deployment-id-cell"
>
<span v-if="shouldRenderDeploymentID" class="text-break-word">
{{ deploymentInternalId }}
@@ -656,7 +662,13 @@ export default {
</div>
</div>
- <div class="table-section d-none d-md-block" :class="tableData.build.spacing" role="gridcell">
+ <div
+ v-if="!isFolder"
+ class="table-section d-none d-md-block"
+ :class="tableData.build.spacing"
+ role="gridcell"
+ data-testid="environment-build-cell"
+ >
<a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray">
<tooltip-on-truncate
:title="buildName"
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 8bd71db957c..e4cf5760987 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
@@ -89,7 +89,9 @@ export default {
.then((response) => this.store.setfolderContent(folder, response.data.environments))
.then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
.catch(() => {
- Flash(s__('Environments|An error occurred while fetching the environments.'));
+ createFlash({
+ message: s__('Environments|An error occurred while fetching the environments.'),
+ });
this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
});
},
@@ -133,7 +135,7 @@ export default {
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
- <gl-tabs content-class="gl-display-none">
+ <gl-tabs :value="activeTab" content-class="gl-display-none">
<gl-tab
v-for="(tab, idx) in tabs"
:key="idx"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index f82d3065ca5..61438872afc 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -152,8 +152,7 @@ export default {
</div>
</div>
<template v-for="(model, i) in sortedEnvironments" :model="model">
- <div
- is="environment-item"
+ <environment-item
:key="`environment-item-${i}`"
:model="model"
:can-read-environment="canReadEnvironment"
@@ -189,8 +188,7 @@ export default {
<template v-else>
<template v-for="(child, index) in model.children">
- <div
- is="environment-item"
+ <environment-item
:key="`environment-row-${i}-${index}`"
:model="child"
:can-read-environment="canReadEnvironment"
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index d5caff1660a..6f701f87261 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -3,9 +3,9 @@
*/
import { isEqual, isFunction, omitBy } from 'lodash';
import Visibility from 'visibilityjs';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import { getParameterByName } from '../../lib/utils/common_utils';
+import createFlash from '~/flash';
import Poll from '../../lib/utils/poll';
+import { getParameterByName } from '../../lib/utils/url_utility';
import { s__ } from '../../locale';
import tabs from '../../vue_shared/components/navigation_tabs.vue';
import tablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
@@ -94,7 +94,9 @@ export default {
errorCallback() {
this.isLoading = false;
- Flash(s__('Environments|An error occurred while fetching the environments.'));
+ createFlash({
+ message: s__('Environments|An error occurred while fetching the environments.'),
+ });
},
postAction({
@@ -109,7 +111,9 @@ export default {
.then(() => this.fetchEnvironments())
.catch((err) => {
this.isLoading = false;
- Flash(isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage);
+ createFlash({
+ message: isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage,
+ });
});
}
},
@@ -163,7 +167,9 @@ export default {
window.location.href = url.join('/');
})
.catch(() => {
- Flash(errorMessage);
+ createFlash({
+ message: errorMessage,
+ });
});
},
@@ -202,6 +208,9 @@ export default {
},
];
},
+ activeTab() {
+ return this.tabs.findIndex(({ isActive }) => isActive) ?? 0;
+ },
},
/**
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index dd320676e98..68b4438831e 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -82,7 +82,7 @@ export default {
<div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()">
<gl-icon :name="collapseIcon" :size="16" class="gl-mr-2" />
</div>
- <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
+ <file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-2" />
<strong
v-gl-tooltip
:title="filePath"
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index c945a9e2316..d402d0336d9 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -48,7 +48,6 @@ export const receiveSettingsError = ({ commit }, { response = {} }) => {
createFlash({
message: `${__('There was an error saving your changes.')} ${message}`,
- type: 'alert',
});
commit(types.UPDATE_SETTINGS_LOADING, false);
};
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 77e40039b43..d86e13ce722 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -196,6 +196,7 @@ export default {
/>
<gl-loading-icon
v-if="isRotating"
+ size="sm"
class="gl-absolute gl-align-self-center gl-right-5 gl-mr-7"
/>
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index e7f4b51c964..dde021b67be 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -1,10 +1,8 @@
<script>
import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import axios from '~/lib/utils/axios_utils';
import { sprintf, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { LEGACY_FLAG } from '../constants';
import FeatureFlagForm from './form.vue';
export default {
@@ -15,59 +13,29 @@ export default {
FeatureFlagForm,
},
mixins: [glFeatureFlagMixin()],
- inject: {
- showUserCallout: {},
- userCalloutId: {
- default: '',
- },
- userCalloutsPath: {
- default: '',
- },
- },
- data() {
- return {
- userShouldSeeNewFlagAlert: this.showUserCallout,
- };
- },
- translations: {
- legacyReadOnlyFlagAlert: s__(
- 'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
- ),
- },
computed: {
...mapState([
'path',
'error',
'name',
'description',
- 'scopes',
'strategies',
'isLoading',
'hasError',
'iid',
'active',
- 'version',
]),
title() {
return this.iid
? `^${this.iid} ${this.name}`
: sprintf(s__('Edit %{name}'), { name: this.name });
},
- deprecated() {
- return this.version === LEGACY_FLAG;
- },
},
created() {
return this.fetchFeatureFlag();
},
methods: {
...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']),
- dismissNewVersionFlagAlert() {
- this.userShouldSeeNewFlagAlert = false;
- axios.post(this.userCalloutsPath, {
- feature_name: this.userCalloutId,
- });
- },
},
};
</script>
@@ -76,9 +44,6 @@ export default {
<gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" />
<template v-else-if="!isLoading && !hasError">
- <gl-alert v-if="deprecated" variant="warning" :dismissible="false" class="gl-my-5">{{
- $options.translations.legacyReadOnlyFlagAlert
- }}</gl-alert>
<div class="gl-display-flex gl-align-items-center gl-mb-4 gl-mt-4">
<gl-toggle
:value="active"
@@ -100,12 +65,10 @@ export default {
<feature-flag-form
:name="name"
:description="description"
- :scopes="scopes"
:strategies="strategies"
:cancel-path="path"
:submit-text="__('Save changes')"
:active="active"
- :version="version"
@handleSubmit="(data) => updateFeatureFlag(data)"
/>
</template>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index d08e8d2b3a1..53909dcf42e 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -3,11 +3,8 @@ import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab
import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex';
-import {
- buildUrlWithCurrentLocation,
- getParameterByName,
- historyPushState,
-} from '~/lib/utils/common_utils';
+import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
import EmptyState from './empty_state.vue';
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 9220077af71..cfd838bf5a1 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -1,8 +1,7 @@
<script>
-import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle, GlIcon } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, NEW_VERSION_FLAG, LEGACY_FLAG } from '../constants';
import { labelForStrategy } from '../utils';
export default {
@@ -14,7 +13,6 @@ export default {
components: {
GlBadge,
GlButton,
- GlIcon,
GlModal,
GlToggle,
},
@@ -35,13 +33,7 @@ export default {
deleteFeatureFlagName: null,
};
},
- translations: {
- legacyFlagReadOnlyAlert: s__('FeatureFlags|Flag is read-only'),
- },
computed: {
- permissions() {
- return this.glFeatures.featureFlagPermissions;
- },
modalTitle() {
return sprintf(s__('FeatureFlags|Delete %{name}?'), {
name: this.deleteFeatureFlagName,
@@ -57,12 +49,6 @@ export default {
},
},
methods: {
- isLegacyFlag(flag) {
- return flag.version !== NEW_VERSION_FLAG;
- },
- statusToggleDisabled(flag) {
- return flag.version === LEGACY_FLAG;
- },
scopeTooltipText(scope) {
return !scope.active
? sprintf(s__('FeatureFlags|Inactive flag for %{scope}'), {
@@ -70,22 +56,6 @@ export default {
})
: '';
},
- badgeText(scope) {
- const displayName =
- scope.environmentScope === '*'
- ? s__('FeatureFlags|* (All environments)')
- : scope.environmentScope;
-
- const displayPercentage =
- scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT
- ? `: ${scope.rolloutPercentage}%`
- : '';
-
- return `${displayName}${displayPercentage}`;
- },
- badgeVariant(scope) {
- return scope.active ? 'info' : 'muted';
- },
strategyBadgeText(strategy) {
return labelForStrategy(strategy);
},
@@ -142,7 +112,6 @@ export default {
<gl-toggle
v-if="featureFlag.update_path"
:value="featureFlag.active"
- :disabled="statusToggleDisabled(featureFlag)"
:label="$options.i18n.toggleLabel"
label-position="hidden"
data-testid="feature-flag-status-toggle"
@@ -169,12 +138,6 @@ export default {
<div class="feature-flag-name text-monospace text-truncate">
{{ featureFlag.name }}
</div>
- <gl-icon
- v-if="isLegacyFlag(featureFlag)"
- v-gl-tooltip.hover="$options.translations.legacyFlagReadOnlyAlert"
- class="gl-ml-3"
- name="information-o"
- />
</div>
<div class="feature-flag-description text-secondary text-truncate">
{{ featureFlag.description }}
@@ -189,27 +152,14 @@ export default {
<div
class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments"
>
- <template v-if="isLegacyFlag(featureFlag)">
- <gl-badge
- v-for="scope in featureFlag.scopes"
- :key="scope.id"
- v-gl-tooltip.hover="scopeTooltipText(scope)"
- :variant="badgeVariant(scope)"
- :data-qa-selector="`feature-flag-scope-${badgeVariant(scope)}-badge`"
- class="gl-mr-3 gl-mt-2"
- >{{ badgeText(scope) }}</gl-badge
- >
- </template>
- <template v-else>
- <gl-badge
- v-for="strategy in featureFlag.strategies"
- :key="strategy.id"
- data-testid="strategy-badge"
- variant="info"
- class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5"
- >{{ strategyBadgeText(strategy) }}</gl-badge
- >
- </template>
+ <gl-badge
+ v-for="strategy in featureFlag.strategies"
+ :key="strategy.id"
+ data-testid="strategy-badge"
+ variant="info"
+ class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5"
+ >{{ strategyBadgeText(strategy) }}</gl-badge
+ >
</div>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 67ddceaf080..f7ad2c1f106 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -1,16 +1,6 @@
<script>
-import {
- GlButton,
- GlBadge,
- GlTooltip,
- GlTooltipDirective,
- GlFormTextarea,
- GlFormCheckbox,
- GlSprintf,
- GlIcon,
- GlToggle,
-} from '@gitlab/ui';
-import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash';
+import { GlButton } from '@gitlab/ui';
+import { memoize, cloneDeep, isNumber, uniqueId } from 'lodash';
import Vue from 'vue';
import { s__ } from '~/locale';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
@@ -20,12 +10,8 @@ import {
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ALL_ENVIRONMENTS_NAME,
- INTERNAL_ID_PREFIX,
NEW_VERSION_FLAG,
- LEGACY_FLAG,
} from '../constants';
-import { createNewEnvironmentScope } from '../store/helpers';
-import EnvironmentsDropdown from './environments_dropdown.vue';
import Strategy from './strategy.vue';
export default {
@@ -35,20 +21,9 @@ export default {
},
components: {
GlButton,
- GlBadge,
- GlFormTextarea,
- GlFormCheckbox,
- GlTooltip,
- GlSprintf,
- GlIcon,
- GlToggle,
- EnvironmentsDropdown,
Strategy,
RelatedIssuesRoot,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
mixins: [featureFlagsMixin()],
inject: {
featureFlagIssuesEndpoint: {
@@ -71,11 +46,6 @@ export default {
required: false,
default: '',
},
- scopes: {
- type: Array,
- required: false,
- default: () => [],
- },
cancelPath: {
type: String,
required: true,
@@ -89,11 +59,6 @@ export default {
required: false,
default: () => [],
},
- version: {
- type: String,
- required: false,
- default: LEGACY_FLAG,
- },
},
translations: {
allEnvironmentsText: s__('FeatureFlags|* (All Environments)'),
@@ -120,35 +85,18 @@ export default {
formName: this.name,
formDescription: this.description,
- // operate on a clone to avoid mutating props
- formScopes: this.scopes.map((s) => ({ ...s })),
formStrategies: cloneDeep(this.strategies),
newScope: '',
};
},
computed: {
- filteredScopes() {
- return this.formScopes.filter((scope) => !scope.shouldBeDestroyed);
- },
filteredStrategies() {
return this.formStrategies.filter((s) => !s.shouldBeDestroyed);
},
- canUpdateFlag() {
- return !this.permissionsFlag || (this.formScopes || []).every((scope) => scope.canUpdate);
- },
- permissionsFlag() {
- return this.glFeatures.featureFlagPermissions;
- },
- supportsStrategies() {
- return this.version === NEW_VERSION_FLAG;
- },
showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0;
},
- readOnly() {
- return this.version === LEGACY_FLAG;
- },
},
methods: {
keyFor(strategy) {
@@ -174,37 +122,6 @@ export default {
isAllEnvironment(name) {
return name === ALL_ENVIRONMENTS_NAME;
},
-
- /**
- * When the user clicks the remove button we delete the scope
- *
- * If the scope has an ID, we need to add the `shouldBeDestroyed` flag.
- * If the scope does *not* have an ID, we can just remove it.
- *
- * This flag will be used when submitting the data to the backend
- * to determine which records to delete (via a "_destroy" property).
- *
- * @param {Object} scope
- */
- removeScope(scope) {
- if (isString(scope.id) && scope.id.startsWith(INTERNAL_ID_PREFIX)) {
- this.formScopes = this.formScopes.filter((s) => s !== scope);
- } else {
- Vue.set(scope, 'shouldBeDestroyed', true);
- }
- },
-
- /**
- * Creates a new scope and adds it to the list of scopes
- *
- * @param overrides An object whose properties will
- * be used override the default scope options
- */
- createNewScope(overrides) {
- this.formScopes.push(createNewEnvironmentScope(overrides, this.permissionsFlag));
- this.newScope = '';
- },
-
/**
* When the user clicks the submit button
* it triggers an event with the form data
@@ -214,61 +131,16 @@ export default {
name: this.formName,
description: this.formDescription,
active: this.active,
- version: this.version,
+ version: NEW_VERSION_FLAG,
+ strategies: this.formStrategies,
};
- if (this.version === LEGACY_FLAG) {
- flag.scopes = this.formScopes;
- } else {
- flag.strategies = this.formStrategies;
- }
-
this.$emit('handleSubmit', flag);
},
- canUpdateScope(scope) {
- return !this.permissionsFlag || scope.canUpdate;
- },
-
isRolloutPercentageInvalid: memoize(function isRolloutPercentageInvalid(percentage) {
return !this.$options.rolloutPercentageRegex.test(percentage);
}),
-
- /**
- * Generates a unique ID for the strategy based on the v-for index
- *
- * @param index The index of the strategy
- */
- rolloutStrategyId(index) {
- return `rollout-strategy-${index}`;
- },
-
- /**
- * Generates a unique ID for the percentage based on the v-for index
- *
- * @param index The index of the percentage
- */
- rolloutPercentageId(index) {
- return `rollout-percentage-${index}`;
- },
- rolloutUserId(index) {
- return `rollout-user-id-${index}`;
- },
-
- shouldDisplayIncludeUserIds(scope) {
- return ![ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_USER_ID].includes(
- scope.rolloutStrategy,
- );
- },
- shouldDisplayUserIds(scope) {
- return scope.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID || scope.shouldIncludeUserIds;
- },
- onStrategyChange(index) {
- const scope = this.filteredScopes[index];
- scope.shouldIncludeUserIds =
- scope.rolloutUserIds.length > 0 &&
- scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
- },
onFormStrategyChange(strategy, index) {
Object.assign(this.filteredStrategies[index], strategy);
},
@@ -281,12 +153,7 @@ export default {
<div class="row">
<div class="form-group col-md-4">
<label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label>
- <input
- id="feature-flag-name"
- v-model="formName"
- :disabled="!canUpdateFlag"
- class="form-control"
- />
+ <input id="feature-flag-name" v-model="formName" class="form-control" />
</div>
</div>
@@ -298,7 +165,6 @@ export default {
<textarea
id="feature-flag-description"
v-model="formDescription"
- :disabled="!canUpdateFlag"
class="form-control"
rows="4"
></textarea>
@@ -312,277 +178,35 @@ export default {
:show-categorized-issues="false"
/>
- <template v-if="supportsStrategies">
- <div class="row">
- <div class="col-md-12">
- <h4>{{ s__('FeatureFlags|Strategies') }}</h4>
- <div class="flex align-items-baseline justify-content-between">
- <p class="mr-3">{{ $options.translations.newHelpText }}</p>
- <gl-button variant="confirm" category="secondary" @click="addStrategy">
- {{ s__('FeatureFlags|Add strategy') }}
- </gl-button>
- </div>
- </div>
- </div>
- <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies">
- <strategy
- v-for="(strategy, index) in filteredStrategies"
- :key="keyFor(strategy)"
- :strategy="strategy"
- :index="index"
- @change="onFormStrategyChange($event, index)"
- @delete="deleteStrategy(strategy)"
- />
- </div>
- <div v-else class="flex justify-content-center border-top py-4 w-100">
- <span>{{ $options.translations.noStrategiesText }}</span>
- </div>
- </template>
-
- <div v-else class="row">
- <div class="form-group col-md-12">
- <h4>{{ s__('FeatureFlags|Target environments') }}</h4>
- <gl-sprintf :message="$options.translations.helpText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- <template #bold="{ content }">
- <b>{{ content }}</b>
- </template>
- </gl-sprintf>
-
- <div class="js-scopes-table gl-mt-3">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-30" role="columnheader">
- {{ s__('FeatureFlags|Environment Spec') }}
- </div>
- <div class="table-section section-20 text-center" role="columnheader">
- {{ s__('FeatureFlags|Status') }}
- </div>
- <div class="table-section section-40" role="columnheader">
- {{ s__('FeatureFlags|Rollout Strategy') }}
- </div>
- </div>
-
- <div
- v-for="(scope, index) in filteredScopes"
- :key="scope.id"
- ref="scopeRow"
- class="gl-responsive-table-row"
- role="row"
- >
- <div class="table-section section-30" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Environment Spec') }}
- </div>
- <div
- class="table-mobile-content gl-display-flex gl-align-items-center gl-justify-content-start"
- >
- <p v-if="isAllEnvironment(scope.environmentScope)" class="js-scope-all pl-3">
- {{ $options.translations.allEnvironmentsText }}
- </p>
-
- <environments-dropdown
- v-else
- class="col-12"
- :value="scope.environmentScope"
- :disabled="!canUpdateScope(scope) || scope.environmentScope !== ''"
- @selectEnvironment="(env) => (scope.environmentScope = env)"
- @createClicked="(env) => (scope.environmentScope = env)"
- @clearInput="(env) => (scope.environmentScope = '')"
- />
-
- <gl-badge v-if="permissionsFlag && scope.protected" variant="success">
- {{ s__('FeatureFlags|Protected') }}
- </gl-badge>
- </div>
- </div>
-
- <div class="table-section section-20 text-center" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ $options.i18n.statusLabel }}
- </div>
- <div class="table-mobile-content gl-display-flex gl-justify-content-center">
- <gl-toggle
- :value="scope.active"
- :disabled="!active || !canUpdateScope(scope)"
- :label="$options.i18n.statusLabel"
- label-position="hidden"
- @change="(status) => (scope.active = status)"
- />
- </div>
- </div>
-
- <div class="table-section section-40" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Rollout Strategy') }}
- </div>
- <div class="table-mobile-content js-rollout-strategy form-inline">
- <label class="sr-only" :for="rolloutStrategyId(index)">
- {{ s__('FeatureFlags|Rollout Strategy') }}
- </label>
- <div class="select-wrapper col-12 col-md-8 p-0">
- <select
- :id="rolloutStrategyId(index)"
- v-model="scope.rolloutStrategy"
- :disabled="!scope.active"
- class="form-control select-control w-100 js-rollout-strategy"
- @change="onStrategyChange(index)"
- >
- <option :value="$options.ROLLOUT_STRATEGY_ALL_USERS">
- {{ s__('FeatureFlags|All users') }}
- </option>
- <option :value="$options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT">
- {{ s__('FeatureFlags|Percent rollout (logged in users)') }}
- </option>
- <option :value="$options.ROLLOUT_STRATEGY_USER_ID">
- {{ s__('FeatureFlags|User IDs') }}
- </option>
- </select>
- <gl-icon
- name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- :size="16"
- />
- </div>
-
- <div
- v-if="scope.rolloutStrategy === $options.ROLLOUT_STRATEGY_PERCENT_ROLLOUT"
- class="d-flex-center mt-2 mt-md-0 ml-md-2"
- >
- <label class="sr-only" :for="rolloutPercentageId(index)">
- {{ s__('FeatureFlags|Rollout Percentage') }}
- </label>
- <div class="gl-w-9">
- <input
- :id="rolloutPercentageId(index)"
- v-model="scope.rolloutPercentage"
- :disabled="!scope.active"
- :class="{
- 'is-invalid': isRolloutPercentageInvalid(scope.rolloutPercentage),
- }"
- type="number"
- min="0"
- max="100"
- :pattern="$options.rolloutPercentageRegex.source"
- class="rollout-percentage js-rollout-percentage form-control text-right w-100"
- />
- </div>
- <gl-tooltip
- v-if="isRolloutPercentageInvalid(scope.rolloutPercentage)"
- :target="rolloutPercentageId(index)"
- >
- {{
- s__(
- 'FeatureFlags|Percent rollout must be an integer number between 0 and 100',
- )
- }}
- </gl-tooltip>
- <span class="ml-1">%</span>
- </div>
- <div class="d-flex flex-column align-items-start mt-2 w-100">
- <gl-form-checkbox
- v-if="shouldDisplayIncludeUserIds(scope)"
- v-model="scope.shouldIncludeUserIds"
- >{{ s__('FeatureFlags|Include additional user IDs') }}</gl-form-checkbox
- >
- <template v-if="shouldDisplayUserIds(scope)">
- <label :for="rolloutUserId(index)" class="mb-2">
- {{ s__('FeatureFlags|User IDs') }}
- </label>
- <gl-form-textarea
- :id="rolloutUserId(index)"
- v-model="scope.rolloutUserIds"
- class="w-100"
- />
- </template>
- </div>
- </div>
- </div>
-
- <div class="table-section section-10 text-right" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Remove') }}
- </div>
- <div class="table-mobile-content">
- <gl-button
- v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
- v-gl-tooltip
- :title="$options.i18n.removeLabel"
- :aria-label="$options.i18n.removeLabel"
- class="js-delete-scope btn-transparent pr-3 pl-3"
- icon="clear"
- data-testid="feature-flag-delete"
- @click="removeScope(scope)"
- />
- </div>
- </div>
- </div>
-
- <div class="gl-responsive-table-row" role="row" data-testid="add-new-scope">
- <div class="table-section section-30" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Environment Spec') }}
- </div>
- <div class="table-mobile-content">
- <environments-dropdown
- class="js-new-scope-name col-12"
- :value="newScope"
- @selectEnvironment="(env) => createNewScope({ environmentScope: env })"
- @createClicked="(env) => createNewScope({ environmentScope: env })"
- />
- </div>
- </div>
-
- <div class="table-section section-20 text-center" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ $options.i18n.statusLabel }}
- </div>
- <div class="table-mobile-content gl-display-flex gl-justify-content-center">
- <gl-toggle
- :disabled="!active"
- :label="$options.i18n.statusLabel"
- label-position="hidden"
- :value="false"
- @change="createNewScope({ active: true })"
- />
- </div>
- </div>
-
- <div class="table-section section-40" role="gridcell">
- <div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Rollout Strategy') }}
- </div>
- <div class="table-mobile-content js-rollout-strategy form-inline">
- <label class="sr-only" for="new-rollout-strategy-placeholder">{{
- s__('FeatureFlags|Rollout Strategy')
- }}</label>
- <div class="select-wrapper col-12 col-md-8 p-0">
- <select
- id="new-rollout-strategy-placeholder"
- disabled
- class="form-control select-control w-100"
- >
- <option>{{ s__('FeatureFlags|All users') }}</option>
- </select>
- <gl-icon
- name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- :size="16"
- />
- </div>
- </div>
- </div>
- </div>
+ <div class="row">
+ <div class="col-md-12">
+ <h4>{{ s__('FeatureFlags|Strategies') }}</h4>
+ <div class="flex align-items-baseline justify-content-between">
+ <p class="mr-3">{{ $options.translations.newHelpText }}</p>
+ <gl-button variant="confirm" category="secondary" @click="addStrategy">
+ {{ s__('FeatureFlags|Add strategy') }}
+ </gl-button>
</div>
</div>
</div>
+ <div v-if="filteredStrategies.length > 0" data-testid="feature-flag-strategies">
+ <strategy
+ v-for="(strategy, index) in filteredStrategies"
+ :key="keyFor(strategy)"
+ :strategy="strategy"
+ :index="index"
+ @change="onFormStrategyChange($event, index)"
+ @delete="deleteStrategy(strategy)"
+ />
+ </div>
+ <div v-else class="flex justify-content-center border-top py-4 w-100">
+ <span>{{ $options.translations.noStrategiesText }}</span>
+ </div>
</fieldset>
<div class="form-actions">
<gl-button
ref="submitButton"
- :disabled="readOnly"
type="button"
variant="confirm"
class="js-ff-submit col-xs-12"
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
index c59e3178b09..5575c6567b5 100644
--- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -80,7 +80,7 @@ export default {
@focus="fetchEnvironments"
@keyup="fetchEnvironments"
/>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<gl-dropdown-item
v-for="environment in results"
v-else-if="results.length"
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
index 19be57f9d27..865c1e677cd 100644
--- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -1,10 +1,8 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import axios from '~/lib/utils/axios_utils';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants';
-import { createNewEnvironmentScope } from '../store/helpers';
+import { ROLLOUT_STRATEGY_ALL_USERS } from '../constants';
import FeatureFlagForm from './form.vue';
export default {
@@ -13,48 +11,14 @@ export default {
GlAlert,
},
mixins: [featureFlagsMixin()],
- inject: {
- showUserCallout: {},
- userCalloutId: {
- default: '',
- },
- userCalloutsPath: {
- default: '',
- },
- },
- data() {
- return {
- userShouldSeeNewFlagAlert: this.showUserCallout,
- };
- },
computed: {
...mapState(['error', 'path']),
- scopes() {
- return [
- createNewEnvironmentScope(
- {
- environmentScope: '*',
- active: true,
- },
- this.glFeatures.featureFlagsPermissions,
- ),
- ];
- },
- version() {
- return NEW_VERSION_FLAG;
- },
strategies() {
return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
},
},
methods: {
...mapActions(['createFeatureFlag']),
- dismissNewVersionFlagAlert() {
- this.userShouldSeeNewFlagAlert = false;
- axios.post(this.userCalloutsPath, {
- feature_name: this.userCalloutId,
- });
- },
},
};
</script>
@@ -69,9 +33,7 @@ export default {
<feature-flag-form
:cancel-path="path"
:submit-text="s__('FeatureFlags|Create feature flag')"
- :scopes="scopes"
:strategies="strategies"
- :version="version"
@handleSubmit="(data) => createFeatureFlag(data)"
/>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
index 45fc37da747..9dbffe75f6b 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue
@@ -76,7 +76,7 @@ export default {
@focus="fetchUserLists"
@keyup="fetchUserLists"
/>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<gl-dropdown-item
v-for="list in userLists"
:key="list.id"
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
index 010674592f8..98dee7c7e97 100644
--- a/app/assets/javascripts/feature_flags/edit.js
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import { parseBoolean } from '~/lib/utils/common_utils';
import EditFeatureFlag from './components/edit_feature_flag.vue';
import createStore from './store/edit';
@@ -16,9 +15,6 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
- userCalloutsPath,
- userCalloutId,
- showUserCallout,
} = el.dataset;
return new Vue({
@@ -30,9 +26,6 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
- userCalloutsPath,
- userCalloutId,
- showUserCallout: parseBoolean(showUserCallout),
},
render(createElement) {
return createElement(EditFeatureFlag);
diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js
index 54c7e8c4453..8656479190a 100644
--- a/app/assets/javascripts/feature_flags/store/edit/actions.js
+++ b/app/assets/javascripts/feature_flags/store/edit/actions.js
@@ -2,8 +2,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { NEW_VERSION_FLAG } from '../../constants';
-import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+import { mapStrategiesToRails } from '../helpers';
import * as types from './mutation_types';
/**
@@ -19,12 +18,7 @@ export const updateFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestUpdateFeatureFlag');
axios
- .put(
- state.endpoint,
- params.version === NEW_VERSION_FLAG
- ? mapStrategiesToRails(params)
- : mapFromScopesViewModel(params),
- )
+ .put(state.endpoint, mapStrategiesToRails(params))
.then(() => {
dispatch('receiveUpdateFeatureFlagSuccess');
visitUrl(state.path);
diff --git a/app/assets/javascripts/feature_flags/store/edit/mutations.js b/app/assets/javascripts/feature_flags/store/edit/mutations.js
index 0a610f4b395..3882cb2dfff 100644
--- a/app/assets/javascripts/feature_flags/store/edit/mutations.js
+++ b/app/assets/javascripts/feature_flags/store/edit/mutations.js
@@ -1,5 +1,5 @@
import { LEGACY_FLAG } from '../../constants';
-import { mapToScopesViewModel, mapStrategiesToViewModel } from '../helpers';
+import { mapStrategiesToViewModel } from '../helpers';
import * as types from './mutation_types';
export default {
@@ -14,7 +14,6 @@ export default {
state.description = response.description;
state.iid = response.iid;
state.active = response.active;
- state.scopes = mapToScopesViewModel(response.scopes);
state.strategies = mapStrategiesToViewModel(response.strategies);
state.version = response.version || LEGACY_FLAG;
},
diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js
index 2fa20e25f4e..300709f2771 100644
--- a/app/assets/javascripts/feature_flags/store/helpers.js
+++ b/app/assets/javascripts/feature_flags/store/helpers.js
@@ -1,149 +1,4 @@
-import { isEmpty, uniqueId, isString } from 'lodash';
-import {
- ROLLOUT_STRATEGY_ALL_USERS,
- ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
- ROLLOUT_STRATEGY_USER_ID,
- ROLLOUT_STRATEGY_GITLAB_USER_LIST,
- INTERNAL_ID_PREFIX,
- DEFAULT_PERCENT_ROLLOUT,
- PERCENT_ROLLOUT_GROUP_ID,
- fetchPercentageParams,
- fetchUserIdParams,
- LEGACY_FLAG,
-} from '../constants';
-
-/**
- * Converts raw scope objects fetched from the API into an array of scope
- * objects that is easier/nicer to bind to in Vue.
- * @param {Array} scopesFromRails An array of scope objects fetched from the API
- */
-export const mapToScopesViewModel = (scopesFromRails) =>
- (scopesFromRails || []).map((s) => {
- const percentStrategy = (s.strategies || []).find(
- (strat) => strat.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
- );
-
- const rolloutPercentage = fetchPercentageParams(percentStrategy) || DEFAULT_PERCENT_ROLLOUT;
-
- const userStrategy = (s.strategies || []).find(
- (strat) => strat.name === ROLLOUT_STRATEGY_USER_ID,
- );
-
- const rolloutStrategy =
- (percentStrategy && percentStrategy.name) ||
- (userStrategy && userStrategy.name) ||
- ROLLOUT_STRATEGY_ALL_USERS;
-
- const rolloutUserIds = (fetchUserIdParams(userStrategy) || '')
- .split(',')
- .filter((id) => id)
- .join(', ');
-
- return {
- id: s.id,
- environmentScope: s.environment_scope,
- active: Boolean(s.active),
- canUpdate: Boolean(s.can_update),
- protected: Boolean(s.protected),
- rolloutStrategy,
- rolloutPercentage,
- rolloutUserIds,
-
- // eslint-disable-next-line no-underscore-dangle
- shouldBeDestroyed: Boolean(s._destroy),
- shouldIncludeUserIds: rolloutUserIds.length > 0 && percentStrategy !== null,
- };
- });
-/**
- * Converts the parameters emitted by the Vue component into
- * the shape that the Rails API expects.
- * @param {Array} scopesFromVue An array of scope objects from the Vue component
- */
-export const mapFromScopesViewModel = (params) => {
- const scopes = (params.scopes || []).map((s) => {
- const parameters = {};
- if (s.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT) {
- parameters.groupId = PERCENT_ROLLOUT_GROUP_ID;
- parameters.percentage = s.rolloutPercentage;
- } else if (s.rolloutStrategy === ROLLOUT_STRATEGY_USER_ID) {
- parameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
- }
-
- const userIdParameters = {};
-
- if (s.shouldIncludeUserIds && s.rolloutStrategy !== ROLLOUT_STRATEGY_USER_ID) {
- userIdParameters.userIds = (s.rolloutUserIds || '').replace(/, /g, ',');
- }
-
- // Strip out any internal IDs
- const id = isString(s.id) && s.id.startsWith(INTERNAL_ID_PREFIX) ? undefined : s.id;
-
- const strategies = [
- {
- name: s.rolloutStrategy,
- parameters,
- },
- ];
-
- if (!isEmpty(userIdParameters)) {
- strategies.push({ name: ROLLOUT_STRATEGY_USER_ID, parameters: userIdParameters });
- }
-
- return {
- id,
- environment_scope: s.environmentScope,
- active: s.active,
- can_update: s.canUpdate,
- protected: s.protected,
- _destroy: s.shouldBeDestroyed,
- strategies,
- };
- });
-
- const model = {
- operations_feature_flag: {
- name: params.name,
- description: params.description,
- active: params.active,
- scopes_attributes: scopes,
- version: LEGACY_FLAG,
- },
- };
-
- return model;
-};
-
-/**
- * Creates a new feature flag environment scope object for use
- * in a Vue component. An optional parameter can be passed to
- * override the property values that are created by default.
- *
- * @param {Object} overrides An optional object whose
- * property values will be used to override the default values.
- *
- */
-export const createNewEnvironmentScope = (overrides = {}, featureFlagPermissions = false) => {
- const defaultScope = {
- environmentScope: '',
- active: false,
- id: uniqueId(INTERNAL_ID_PREFIX),
- rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS,
- rolloutPercentage: DEFAULT_PERCENT_ROLLOUT,
- rolloutUserIds: '',
- };
-
- const newScope = {
- ...defaultScope,
- ...overrides,
- };
-
- if (featureFlagPermissions) {
- newScope.canUpdate = true;
- newScope.protected = false;
- }
-
- return newScope;
-};
+import { ROLLOUT_STRATEGY_GITLAB_USER_LIST, NEW_VERSION_FLAG } from '../constants';
const mapStrategyScopesToRails = (scopes) =>
scopes.length === 0
@@ -206,8 +61,8 @@ export const mapStrategiesToRails = (params) => ({
operations_feature_flag: {
name: params.name,
description: params.description,
- version: params.version,
active: params.active,
strategies_attributes: (params.strategies || []).map(mapStrategyToRails),
+ version: NEW_VERSION_FLAG,
},
});
diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js
index 54e48a4b80c..7e08440c299 100644
--- a/app/assets/javascripts/feature_flags/store/index/mutations.js
+++ b/app/assets/javascripts/feature_flags/store/index/mutations.js
@@ -1,10 +1,7 @@
import Vue from 'vue';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
-import { mapToScopesViewModel } from '../helpers';
import * as types from './mutation_types';
-const mapFlag = (flag) => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
-
const updateFlag = (state, flag) => {
const index = state.featureFlags.findIndex(({ id }) => id === flag.id);
Vue.set(state.featureFlags, index, flag);
@@ -31,7 +28,7 @@ export default {
[types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
state.isLoading = false;
state.hasError = false;
- state.featureFlags = (response.data.feature_flags || []).map(mapFlag);
+ state.featureFlags = response.data.feature_flags || [];
const paginationInfo = createPaginationInfo(response.headers);
state.count = paginationInfo?.total ?? state.featureFlags.length;
@@ -58,7 +55,7 @@ export default {
updateFlag(state, flag);
},
[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](state, data) {
- updateFlag(state, mapFlag(data));
+ updateFlag(state, data);
},
[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
const flag = state.featureFlags.find(({ id }) => i === id);
diff --git a/app/assets/javascripts/feature_flags/store/new/actions.js b/app/assets/javascripts/feature_flags/store/new/actions.js
index d0a1c77a69e..dc3f7a21cdb 100644
--- a/app/assets/javascripts/feature_flags/store/new/actions.js
+++ b/app/assets/javascripts/feature_flags/store/new/actions.js
@@ -1,7 +1,6 @@
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import { NEW_VERSION_FLAG } from '../../constants';
-import { mapFromScopesViewModel, mapStrategiesToRails } from '../helpers';
+import { mapStrategiesToRails } from '../helpers';
import * as types from './mutation_types';
/**
@@ -17,12 +16,7 @@ export const createFeatureFlag = ({ state, dispatch }, params) => {
dispatch('requestCreateFeatureFlag');
return axios
- .post(
- state.endpoint,
- params.version === NEW_VERSION_FLAG
- ? mapStrategiesToRails(params)
- : mapFromScopesViewModel(params),
- )
+ .post(state.endpoint, mapStrategiesToRails(params))
.then(() => {
dispatch('receiveCreateFeatureFlagSuccess');
visitUrl(state.path);
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
index 7b4bed69fb8..747f368b671 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
@@ -10,10 +10,10 @@ export function dismiss(endpoint, highlightId) {
feature_name: highlightId,
})
.catch(() =>
- Flash(
- __(
+ createFlash({
+ message: __(
'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.',
),
- ),
+ }),
);
}
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 38a5bdd4a71..d00e6e59cf5 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -75,6 +75,13 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
icon: 'approval',
tag: '@approved-by',
},
+ tokenAlternative: {
+ formattedKey: __('Approved-By'),
+ key: 'approved-by',
+ type: 'string',
+ param: 'usernames',
+ symbol: '@',
+ },
condition: [
{
url: 'approved_by_usernames[]=None',
@@ -105,7 +112,11 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
const tokenPosition = 3;
IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
- IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(
+ tokenPosition,
+ 0,
+ ...[approvedBy.token, approvedBy.tokenAlternative],
+ );
IssuableTokenKeys.conditions.push(...approvedBy.condition);
const environmentToken = {
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 626a5669067..e0281b8f443 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -1,3 +1,4 @@
+import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownEmoji from './dropdown_emoji';
@@ -87,6 +88,7 @@ export default class AvailableDropdownMappings {
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
+ preprocessing: (milestones) => milestones.sort(sortMilestonesByDueDate),
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 35c79891458..545719ee681 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -1,6 +1,6 @@
+import createFlash from '~/flash';
import { __ } from '~/locale';
import AjaxFilter from '../droplab/plugins/ajax_filter';
-import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchTokenizer from './filtered_search_tokenizer';
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index 91af3a6b812..a7648a3c463 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,7 +1,7 @@
+import createFlash from '~/flash';
import { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 93051b00756..f78644a3893 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,7 @@
+import createFlash from '~/flash';
import { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 707205a6502..5ba69f052c9 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,7 +1,7 @@
import { last } from 'lodash';
import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -10,9 +10,8 @@ import {
DOWN_KEY_CODE,
} from '~/lib/utils/keycodes';
import { __ } from '~/locale';
-import createFlash from '../flash';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
-import { visitUrl } from '../lib/utils/url_utility';
+import { visitUrl, getUrlParamsArray, getParameterByName } from '../lib/utils/url_utility';
import FilteredSearchContainer from './container';
import DropdownUtils from './dropdown_utils';
import eventHub from './event_hub';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index eec4db41b0a..7143cb50ea6 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,4 +1,5 @@
-import { objectToQueryString, spriteIcon } from '~/lib/utils/common_utils';
+import { spriteIcon } from '~/lib/utils/common_utils';
+import { objectToQuery } from '~/lib/utils/url_utility';
import FilteredSearchContainer from './container';
import VisualTokenValue from './visual_token_value';
@@ -327,7 +328,7 @@ export default class FilteredSearchVisualTokens {
return endpoint;
}
- const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
+ const queryString = objectToQuery(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 7f4445ad4c7..707add10009 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -4,7 +4,7 @@ import * as Emoji from '~/emoji';
import FilteredSearchContainer from '~/filtered_search/container';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
@@ -83,7 +83,11 @@ export default class VisualTokenValue {
matchingLabel.text_color,
);
})
- .catch(() => new Flash(__('An error occurred while fetching label colors.')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while fetching label colors.'),
+ }),
+ );
}
updateEpicLabel(tokenValueContainer) {
@@ -105,7 +109,11 @@ export default class VisualTokenValue {
VisualTokenValue.replaceEpicTitle(tokenValueContainer, matchingEpic.title, matchingEpic.id);
})
- .catch(() => new Flash(__('An error occurred while adding formatted title for epic')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while adding formatted title for epic'),
+ }),
+ );
}
static replaceEpicTitle(tokenValueContainer, epicTitle, epicId) {
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 2edb6e79d3b..741171b185a 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -125,38 +125,11 @@ const createFlash = function createFlash({
return flashContainer;
};
-/*
- * Flash banner supports different types of Flash configurations
- * along with ability to provide actionConfig which can be used to show
- * additional action or link on banner next to message
- *
- * @param {String} message Flash message text
- * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
- * @param {Object} parent Reference to parent element under which Flash needs to appear
- * @param {Object} actionConfig Map of config to show action on banner
- * @param {String} href URL to which action config should point to (default: '#')
- * @param {String} title Title of action
- * @param {Function} clickHandler Method to call when action is clicked on
- * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
- */
-const deprecatedCreateFlash = function deprecatedCreateFlash(
- message,
- type,
- parent,
- actionConfig,
- fadeTransition,
- addBodyClass,
-) {
- return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass });
-};
-
export {
createFlash as default,
- deprecatedCreateFlash,
createFlashEl,
createAction,
hideFlash,
removeFlashClickListener,
FLASH_TYPES,
};
-window.Flash = createFlash;
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index 893b74a9895..0fb70fb831e 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -1,9 +1,8 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar';
-const isRefactoring = document.body.classList.contains('sidebar-refactoring');
const HIDE_INTERVAL_TIMEOUT = 300;
-const COLLAPSED_PANEL_WIDTH = isRefactoring ? 48 : 50;
+const COLLAPSED_PANEL_WIDTH = 48;
const IS_OVER_CLASS = 'is-over';
const IS_ABOVE_CLASS = 'is-above';
const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out';
@@ -89,12 +88,12 @@ export const moveSubItemsToPosition = (el, subItems) => {
const boundingRect = el.getBoundingClientRect();
const left = sidebar ? sidebar.offsetWidth : COLLAPSED_PANEL_WIDTH;
let top = calculateTop(boundingRect, subItems.offsetHeight);
- if (isRefactoring && hasSubItems) {
- top -= header.offsetHeight;
- } else if (isRefactoring) {
+ const isAbove = top < boundingRect.top;
+ if (hasSubItems) {
+ top = isAbove ? top : top - header.offsetHeight;
+ } else {
top = boundingRect.top;
}
- const isAbove = top < boundingRect.top;
subItems.classList.add('fly-out-list');
subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - getHeaderHeight()}px, 0)`; // eslint-disable-line no-param-reassign
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index d6fcdeb9e13..1137951ccfc 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,16 +1,18 @@
<script>
/* eslint-disable vue/require-default-prop, vue/no-v-html */
+import { GlButton } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
-import Identicon from '~/vue_shared/components/identicon.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
const trackingMixin = Tracking.mixin();
export default {
components: {
- Identicon,
+ GlButton,
+ ProjectAvatar,
},
mixins: [trackingMixin],
inject: ['vuexModule'],
@@ -56,24 +58,18 @@ export default {
<template>
<li class="frequent-items-list-item-container">
- <a
+ <gl-button
+ category="tertiary"
:href="webUrl"
- class="clearfix dropdown-item"
+ class="gl-text-left gl-justify-content-start!"
@click="track('click_link', { label: `${dropdownType}_dropdown_frequent_items_list_item` })"
>
- <div
- ref="frequentItemsItemAvatarContainer"
- class="frequent-items-item-avatar-container avatar-container rect-avatar s32"
- >
- <img v-if="avatarUrl" ref="frequentItemsItemAvatar" :src="avatarUrl" class="avatar s32" />
- <identicon
- v-else
- :entity-id="itemId"
- :entity-name="itemName"
- size-class="s32"
- class="rect-avatar"
- />
- </div>
+ <project-avatar
+ class="gl-float-left gl-mr-3"
+ :project-avatar-url="avatarUrl"
+ :project-name="itemName"
+ aria-hidden="true"
+ />
<div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container">
<div
ref="frequentItemsItemTitle"
@@ -90,6 +86,6 @@ export default {
{{ truncatedNamespace }}
</div>
</div>
- </a>
+ </gl-button>
</li>
</template>
diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js
index 90b454d1b42..65a762f54ad 100644
--- a/app/assets/javascripts/frequent_items/store/actions.js
+++ b/app/assets/javascripts/frequent_items/store/actions.js
@@ -1,4 +1,5 @@
import AccessorUtilities from '~/lib/utils/accessor';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { getGroups, getProjects } from '~/rest_api';
import { getTopFrequentItems } from '../utils';
import * as types from './mutation_types';
@@ -51,7 +52,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => {
const params = {
simple: true,
per_page: 20,
- membership: Boolean(gon.current_user_id),
+ membership: isLoggedIn(),
};
let searchFunction;
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index fa6f07edfcf..7964e762dac 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -1,7 +1,8 @@
import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { parseQueryStringIntoObject } from '~/lib/utils/common_utils';
+import { queryToObject } from '~/lib/utils/url_utility';
+
import { __ } from '~/locale';
export default class GpgBadges {
@@ -27,7 +28,7 @@ export default class GpgBadges {
return Promise.reject(new Error(__('Missing commit signatures endpoint!')));
}
- const params = parseQueryStringIntoObject(tag.serialize());
+ const params = queryToObject(tag.serialize());
return axios
.get(endpoint, { params })
.then(({ data }) => {
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js
index 77d2acd3393..25347ad6433 100644
--- a/app/assets/javascripts/grafana_integration/store/actions.js
+++ b/app/assets/javascripts/grafana_integration/store/actions.js
@@ -40,6 +40,5 @@ export const receiveGrafanaIntegrationUpdateError = (_, error) => {
createFlash({
message: `${__('There was an error saving your changes.')} ${message}`,
- type: 'alert',
});
};
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 7e897be9e9a..aad7712a9f0 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,2 +1,11 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-export const IssueType = 'Issue';
+export const TYPE_CI_RUNNER = 'Ci::Runner';
+export const TYPE_GROUP = 'Group';
+export const TYPE_ISSUE = 'Issue';
+export const TYPE_ITERATION = 'Iteration';
+export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence';
+export const TYPE_MERGE_REQUEST = 'MergeRequest';
+export const TYPE_MILESTONE = 'Milestone';
+export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
+export const TYPE_SITE_PROFILE = 'DastSiteProfile';
+export const TYPE_USER = 'User';
+export const TYPE_VULNERABILITY = 'Vulnerability';
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index e64e8009a5f..18f9a50bbce 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -18,11 +18,6 @@ export const MutationOperationMode = {
};
/**
- * Possible GraphQL entity types.
- */
-export const TYPE_GROUP = 'Group';
-
-/**
* Ids generated by GraphQL endpoints are usually in the format
* gid://gitlab/Groups/123. This method takes a type and an id
* and interpolates the 2 values into the expected GraphQL ID format.
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index c1fc75fbea6..b6a1f41afb5 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
import { slugify } from './lib/utils/text_utility';
@@ -16,7 +16,7 @@ export default class Group {
if (groupName.value === '') {
groupName.addEventListener('keyup', this.updateHandler);
- groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
+ groupName.addEventListener('keyup', this.updateGroupPathSlugHandler);
}
});
@@ -61,11 +61,15 @@ export default class Group {
element.value = suggestedSlug;
});
} else if (exists && !suggests.length) {
- flash(__('Unable to suggest a path. Please refresh and try again.'));
+ createFlash({
+ message: __('Unable to suggest a path. Please refresh and try again.'),
+ });
}
})
.catch(() =>
- flash(__('An error occurred while checking group path. Please refresh and try again.')),
+ createFlash({
+ message: __('An error occurred while checking group path. Please refresh and try again.'),
+ }),
);
}
}
diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js
index 257f5ac9658..378259eb9c8 100644
--- a/app/assets/javascripts/group_label_subscription.js
+++ b/app/assets/javascripts/group_label_subscription.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { __ } from '~/locale';
import { fixTitle, hide } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
const tooltipTitles = {
@@ -30,7 +30,11 @@ export default class GroupLabelSubscription {
this.toggleSubscriptionButtons();
this.$unsubscribeButtons.removeAttr('data-url');
})
- .catch(() => flash(__('There was an error when unsubscribing from this label.')));
+ .catch(() =>
+ createFlash({
+ message: __('There was an error when unsubscribing from this label.'),
+ }),
+ );
}
subscribe(event) {
@@ -45,7 +49,11 @@ export default class GroupLabelSubscription {
.post(url)
.then(() => GroupLabelSubscription.setNewTooltip($btn))
.then(() => this.toggleSubscriptionButtons())
- .catch(() => flash(__('There was an error when subscribing to this label.')));
+ .catch(() =>
+ createFlash({
+ message: __('There was an error when subscribing to this label.'),
+ }),
+ );
}
toggleSubscriptionButtons() {
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index a1d706f0f66..f61d96b3dfd 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -101,7 +101,7 @@ export default {
<h4 class="gl-display-flex gl-align-items-center">
{{ __('Set up shared runner availability') }}
- <gl-loading-icon v-if="isLoading" class="gl-ml-3" inline />
+ <gl-loading-icon v-if="isLoading" class="gl-ml-3" size="sm" inline />
</h4>
<section class="gl-mt-5">
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 9d2c7cfe581..2a95b242510 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,10 +1,8 @@
<script>
-/* global Flash */
-
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { getParameterByName } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
import { HIDDEN_CLASS } from '~/lib/utils/constants';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
@@ -116,7 +114,7 @@ export default {
this.isLoading = false;
window.scrollTo({ top: 0, behavior: 'smooth' });
- Flash(COMMON_STR.FAILURE);
+ createFlash({ message: COMMON_STR.FAILURE });
});
},
fetchAllGroups() {
@@ -202,7 +200,7 @@ export default {
if (err.status === 403) {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
- Flash(message);
+ createFlash({ message });
this.targetGroup.isBeingRemoved = false;
});
},
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index dbad2688451..ad0b27c9693 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlAvatar,
GlLoadingIcon,
GlBadge,
GlIcon,
@@ -7,7 +8,6 @@ import {
GlSafeHtmlDirective,
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
-import identicon from '~/vue_shared/components/identicon.vue';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants';
import eventHub from '../event_hub';
@@ -23,11 +23,11 @@ export default {
SafeHtml: GlSafeHtmlDirective,
},
components: {
+ GlAvatar,
GlBadge,
GlLoadingIcon,
GlIcon,
UserAccessRoleBadge,
- identicon,
itemCaret,
itemTypeIcon,
itemStats,
@@ -125,21 +125,21 @@ export default {
size="lg"
class="d-none d-sm-inline-flex flex-shrink-0 gl-mr-3"
/>
- <div
- :class="{ 'd-sm-flex': !group.isChildrenLoading }"
- class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0"
+ <a
+ :class="{ 'gl-sm-display-flex': !group.isChildrenLoading }"
+ class="gl-display-none gl-text-decoration-none! gl-mr-3"
+ :href="group.relativePath"
+ :aria-label="group.name"
>
- <a :href="group.relativePath" class="no-expand">
- <img
- v-if="hasAvatar"
- :src="group.avatarUrl"
- data-testid="group-avatar"
- class="avatar s40"
- :itemprop="microdata.imageItemprop"
- />
- <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
- </a>
- </div>
+ <gl-avatar
+ shape="rect"
+ :entity-name="group.name"
+ :src="group.avatarUrl"
+ :alt="group.name"
+ :size="32"
+ :itemprop="microdata.imageItemprop"
+ />
+ </a>
<div class="group-text-container d-flex flex-fill align-items-center">
<div class="group-text flex-grow-1 flex-shrink-1">
<div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
@@ -178,7 +178,7 @@ export default {
</div>
</div>
<div v-if="isGroupPendingRemoval">
- <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
+ <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
</div>
<div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between">
<item-actions
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index d407fdd2b90..59a37b2a1d5 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -1,6 +1,6 @@
<script>
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import { getParameterByName } from '../../lib/utils/common_utils';
+import { getParameterByName } from '../../lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index e09df8a5d26..7a37d1eb93d 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -73,7 +73,7 @@ export default {
icon-name="star"
/>
<div v-if="isProjectPendingRemoval">
- <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
+ <gl-badge variant="warning">{{ __('pending deletion') }}</gl-badge>
</div>
<div v-if="isProject" class="last-updated">
<time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js
index cedf16cd7f1..a7d44322eb1 100644
--- a/app/assets/javascripts/groups/groups_filterable_list.js
+++ b/app/assets/javascripts/groups/groups_filterable_list.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import FilterableList from '~/filterable_list';
-import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
+import { normalizeHeaders } from '../lib/utils/common_utils';
+import { getParameterByName } from '../lib/utils/url_utility';
import eventHub from './event_hub';
export default class GroupFilterableList extends FilterableList {
@@ -45,7 +46,7 @@ export default class GroupFilterableList extends FilterableList {
onFilterInput() {
const queryData = {};
const $form = $(this.form);
- const archivedParam = getParameterByName('archived', window.location.href);
+ const archivedParam = getParameterByName('archived');
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) {
@@ -85,11 +86,11 @@ export default class GroupFilterableList extends FilterableList {
// Get option query param, also preserve currently applied query param
const sortParam = getParameterByName(
'sort',
- isOptionFilterBySort ? e.currentTarget.href : window.location.href,
+ isOptionFilterBySort ? e.currentTarget.search : window.location.search,
);
const archivedParam = getParameterByName(
'archived',
- isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href,
+ isOptionFilterByArchivedProjects ? e.currentTarget.search : window.location.search,
);
if (sortParam) {
diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue
index d3a52f9f0cf..2b75d10f659 100644
--- a/app/assets/javascripts/ide/components/error_message.vue
+++ b/app/assets/javascripts/ide/components/error_message.vue
@@ -57,6 +57,6 @@ export default {
@primaryAction="doAction"
>
<span v-html="message.text"></span>
- <gl-loading-icon v-show="isLoading" inline class="vertical-align-middle ml-1" />
+ <gl-loading-icon v-show="isLoading" size="sm" inline class="vertical-align-middle ml-1" />
</gl-alert>
</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 0c9fd324f8c..e345e5dc099 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -130,7 +130,6 @@ export default {
<div class="ide-view flex-grow d-flex">
<template v-if="loadDeferred">
<find-file
- v-show="fileFindVisible"
:files="allBlobs"
:visible="fileFindVisible"
:loading="loading"
diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue
index 36891505230..1c25a8e634d 100644
--- a/app/assets/javascripts/ide/components/ide_project_header.vue
+++ b/app/assets/javascripts/ide/components/ide_project_header.vue
@@ -1,9 +1,9 @@
<script>
-import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
components: {
- ProjectAvatarDefault,
+ ProjectAvatar,
},
props: {
project: {
@@ -16,8 +16,12 @@ export default {
<template>
<div class="context-header ide-context-header">
- <a :href="project.web_url" :title="s__('IDE|Go to project')">
- <project-avatar-default :project="project" :size="48" />
+ <a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link">
+ <project-avatar
+ :project-name="project.name"
+ :project-avatar-url="project.avatar_url"
+ :size="48"
+ />
<span class="ide-sidebar-project-title">
<span class="sidebar-context-title"> {{ project.name }} </span>
<span
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 6c7f084c164..938385f0b81 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -79,7 +79,7 @@ export default {
<gl-icon :name="collapseIcon" class="ide-stage-collapse-icon" />
</div>
<div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0">
- <gl-loading-icon v-if="showLoadingIcon" />
+ <gl-loading-icon v-if="showLoadingIcon" size="sm" />
<template v-else>
<item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" />
</template>
diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue
index 639937481f3..2d9f74a06ee 100644
--- a/app/assets/javascripts/ide/components/merge_requests/item.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/item.vue
@@ -41,7 +41,7 @@ export default {
<template>
<a :href="mergeRequestHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
+ <gl-icon v-if="isActive" :size="16" name="mobile-issue-close" />
</span>
<span>
<strong> {{ item.title }} </strong>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index f8dc10420d0..e8541d3a4c3 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -61,9 +61,6 @@ export default {
message: sprintf(s__('The name "%{name}" is already taken in this directory.'), {
name: this.entryName,
}),
- type: 'alert',
- parent: document,
- actionConfig: null,
fadeTransition: false,
addBodyClass: true,
});
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index 4d35e946d89..838c363a6a3 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -126,7 +126,11 @@ export default {
class="ide-navigator-location form-control bg-white"
readonly
/>
- <gl-loading-icon v-if="loading" class="position-absolute ide-preview-loading-icon" />
+ <gl-loading-icon
+ v-if="loading"
+ size="sm"
+ class="position-absolute ide-preview-loading-icon"
+ />
</div>
</header>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index bf2af9ffd49..5c711313ff6 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -6,9 +6,9 @@ import {
EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants';
-import EditorLite from '~/editor/editor_lite';
-import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
+import SourceEditor from '~/editor/source_editor';
+import createFlash from '~/flash';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
@@ -216,7 +216,7 @@ export default {
},
mounted() {
if (!this.globalEditor) {
- this.globalEditor = new EditorLite();
+ this.globalEditor = new SourceEditor();
}
this.initEditor();
@@ -250,14 +250,11 @@ export default {
this.createEditorInstance();
})
.catch((err) => {
- flash(
- __('Error setting up editor. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
+ createFlash({
+ message: __('Error setting up editor. Please try again.'),
+ fadeTransition: false,
+ addBodyClass: true,
+ });
throw err;
});
},
@@ -418,7 +415,11 @@ export default {
const parentPath = getPathParent(this.file.path);
const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`;
- return this.addTempImage({ name: path, rawPath: content }).then(({ name: fileName }) => {
+ return this.addTempImage({
+ name: path,
+ rawPath: URL.createObjectURL(file),
+ content: atob(content.split('base64,')[1]),
+ }).then(({ name: fileName }) => {
this.editor.replaceSelectedText(`![${fileName}](./${fileName})`);
});
});
diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue
index ed0dab47947..14052c23a0c 100644
--- a/app/assets/javascripts/ide/components/shared/tokened_input.vue
+++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue
@@ -82,7 +82,7 @@ export default {
<div class="value-container rounded">
<div class="value">{{ token.label }}</div>
<div class="remove-token inverted">
- <gl-icon :size="10" name="close" use-deprecated-sizes />
+ <gl-icon :size="16" name="close" />
</div>
</div>
</button>
diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue
index 08fb2f5e5a0..c91a98c9527 100644
--- a/app/assets/javascripts/ide/components/terminal/terminal.vue
+++ b/app/assets/javascripts/ide/components/terminal/terminal.vue
@@ -93,7 +93,7 @@ export default {
<div class="d-flex flex-column flex-fill min-height-0 pr-3">
<div class="top-bar d-flex border-left-0 align-items-center">
<div v-if="loadingText" data-qa-selector="loading_container">
- <gl-loading-icon :inline="true" />
+ <gl-loading-icon size="sm" :inline="true" />
<span>{{ loadingText }}</span>
</div>
<terminal-controls
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 5f60bf0269d..27cedd80347 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -111,14 +111,11 @@ export const createRouter = (store, defaultBranch) => {
}
})
.catch((e) => {
- flash(
- __('Error while loading the project data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
+ createFlash({
+ message: __('Error while loading the project data. Please try again.'),
+ fadeTransition: false,
+ addBodyClass: true,
+ });
throw e;
});
}
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index 1d051062637..682914df9ec 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -1,5 +1,6 @@
import { throttle } from 'lodash';
import { Range } from 'monaco-editor';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import Disposable from '../common/disposable';
import DirtyDiffWorker from './diff_worker';
@@ -31,7 +32,7 @@ export default class DirtyDiffController {
this.modelManager = modelManager;
this.decorationsController = decorationsController;
this.dirtyDiffWorker = new DirtyDiffWorker();
- this.throttledComputeDiff = throttle(this.computeDiff, 250);
+ this.throttledComputeDiff = throttle(this.computeDiff, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
this.decorate = this.decorate.bind(this);
this.dirtyDiffWorker.addEventListener('message', this.decorate);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 6bd28cd4fb6..ef4f47f226a 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -100,7 +100,7 @@ export default {
return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha);
},
pingUsage(projectPath) {
- const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
+ const url = `${gon.relative_url_root}/${projectPath}/service_ping/web_ide_pipelines_count`;
return axios.post(url);
},
getCiConfig(projectPath, content) {
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 062dc150805..b22e58a376d 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,6 @@
import { escape } from 'lodash';
import Vue from 'vue';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import {
@@ -36,16 +36,13 @@ export const createTempEntry = (
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
if (getters.entryExists(name)) {
- flash(
- sprintf(__('The name "%{name}" is already taken in this directory.'), {
+ createFlash({
+ message: sprintf(__('The name "%{name}" is already taken in this directory.'), {
name: name.split('/').pop(),
}),
- 'alert',
- document,
- null,
- false,
- true,
- );
+ fadeTransition: false,
+ addBodyClass: true,
+ });
return undefined;
}
@@ -79,11 +76,11 @@ export const createTempEntry = (
return file;
};
-export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) =>
+export const addTempImage = ({ dispatch, getters }, { name, rawPath = '', content = '' }) =>
dispatch('createTempEntry', {
name: getters.getAvailableFileName(name),
type: 'blob',
- content: rawPath.split('base64,')[1],
+ content,
rawPath,
openFile: false,
makeFileActive: false,
@@ -284,14 +281,11 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force =
if (e.response.status === 404) {
reject(e);
} else {
- flash(
- __('Error loading branch data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
+ createFlash({
+ message: __('Error loading branch data. Please try again.'),
+ fadeTransition: false,
+ addBodyClass: true,
+ });
reject(
new Error(
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 5e020f16104..f3f603d4ae9 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -36,9 +36,6 @@ export const getMergeRequestsForBranch = (
.catch((e) => {
createFlash({
message: __(`Error fetching merge requests for ${branchId}`),
- type: 'alert',
- parent: document,
- actionConfig: null,
fadeTransition: false,
addBodyClass: true,
});
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 120a577d44a..93ad19ba81e 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,5 +1,5 @@
import { escape } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import api from '../../../api';
import service from '../../services';
@@ -19,14 +19,11 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force
resolve(data);
})
.catch(() => {
- flash(
- __('Error loading project data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
+ createFlash({
+ message: __('Error loading project data. Please try again.'),
+ fadeTransition: false,
+ addBodyClass: true,
+ });
reject(new Error(`Project not loaded ${namespace}/${projectId}`));
});
} else {
@@ -45,7 +42,11 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {})
});
})
.catch((e) => {
- flash(__('Error loading last commit.'), 'alert', document, null, false, true);
+ createFlash({
+ message: __('Error loading last commit.'),
+ fadeTransition: false,
+ addBodyClass: true,
+ });
throw e;
});
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
index 2bebf8b90ce..e36419cd7eb 100644
--- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
@@ -3,7 +3,7 @@ import axios from '~/lib/utils/axios_utils';
export const pingUsage = ({ rootGetters }) => {
const { web_url: projectUrl } = rootGetters.currentProject;
- const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`;
+ const url = `${projectUrl}/service_ping/web_ide_clientside_preview`;
return axios.post(url);
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 29555799074..2ff71523b1b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
import { leftSidebarViews } from '../../../constants';
@@ -143,7 +143,11 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(types.UPDATE_LOADING, false);
if (!data.short_id) {
- flash(data.message, 'alert', document, null, false, true);
+ createFlash({
+ message: data.message,
+ fadeTransition: false,
+ addBodyClass: true,
+ });
return null;
}
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 4019703b296..0cef3b98e61 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -252,10 +252,10 @@ export function extractMarkdownImagesFromEntries(mdFile, entries) {
.trim();
const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw;
+ const imageRawPath = entries[imagePath]?.rawPath;
if (!isAbsolute(path) && imageContent) {
- const ext = path.includes('.') ? path.split('.').pop().trim() : 'jpeg';
- const src = `data:image/${ext};base64,${imageContent}`;
+ const src = imageRawPath;
i += 1;
const key = `{{${prefix}${i}}}`;
images[key] = { alt, src, title };
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
new file mode 100644
index 00000000000..44d6d17232f
--- /dev/null
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlSearchBoxByType,
+ },
+ inheritAttrs: false,
+ props: {
+ namespaces: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return { searchTerm: '' };
+ },
+ computed: {
+ filteredNamespaces() {
+ return this.namespaces.filter((ns) =>
+ ns.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
+ data-qa-selector="target_namespace_selector_dropdown"
+ v-bind="$attrs"
+ >
+ <template #header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <slot :namespaces="filteredNamespaces"></slot>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 3daa5eebcb6..cb7e3ef9632 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -227,7 +227,12 @@ export default {
</template>
</gl-sprintf>
</span>
- <gl-search-box-by-click class="gl-ml-auto" @submit="filter = $event" @clear="filter = ''" />
+ <gl-search-box-by-click
+ class="gl-ml-auto"
+ :placeholder="s__('BulkImport|Filter by source group')"
+ @submit="filter = $event"
+ @clear="filter = ''"
+ />
</div>
<gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
<template v-else>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
index 63c18f4d78e..1c3ede769e0 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
@@ -1,7 +1,6 @@
<script>
import {
GlButton,
- GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
@@ -11,6 +10,7 @@ import {
} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import ImportGroupDropdown from '../../components/group_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql';
@@ -22,8 +22,8 @@ const DEBOUNCE_INTERVAL = 300;
export default {
components: {
ImportStatus,
+ ImportGroupDropdown,
GlButton,
- GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
@@ -83,6 +83,10 @@ export default {
},
computed: {
+ availableNamespaceNames() {
+ return this.availableNamespaces.map((ns) => ns.full_path);
+ },
+
importTarget() {
return this.group.import_target;
},
@@ -153,9 +157,11 @@ export default {
disabled: isAlreadyImported,
}"
>
- <gl-dropdown
+ <import-group-dropdown
+ #default="{ namespaces }"
:text="importTarget.target_namespace"
:disabled="isAlreadyImported"
+ :namespaces="availableNamespaceNames"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="import-entities-namespace-dropdown gl-h-7 gl-flex-grow-1"
data-qa-selector="target_namespace_selector_dropdown"
@@ -163,22 +169,22 @@ export default {
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
s__('BulkImport|No parent')
}}</gl-dropdown-item>
- <template v-if="availableNamespaces.length">
+ <template v-if="namespaces.length">
<gl-dropdown-divider />
<gl-dropdown-section-header>
{{ s__('BulkImport|Existing groups') }}
</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="ns in availableNamespaces"
- :key="ns.full_path"
+ v-for="ns in namespaces"
+ :key="ns"
data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns.full_path"
- @click="$emit('update-target-namespace', ns.full_path)"
+ :data-qa-group-name="ns"
+ @click="$emit('update-target-namespace', ns)"
>
- {{ ns.full_path }}
+ {{ ns }}
</gl-dropdown-item>
</template>
- </gl-dropdown>
+ </import-group-dropdown>
<div
class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index be09052fb7e..14d08caef34 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -47,18 +47,7 @@ export default {
},
availableNamespaces() {
- const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({
- id: fullPath,
- text: fullPath,
- }));
-
- return [
- { text: __('Groups'), children: serializedNamespaces },
- {
- text: __('Users'),
- children: [{ id: this.defaultTargetNamespace, text: this.defaultTargetNamespace }],
- },
- ];
+ return this.namespaces.map(({ fullPath }) => fullPath);
},
importAllButtonText() {
@@ -179,6 +168,7 @@ export default {
:key="repo.importSource.providerLink"
:repo="repo"
:available-namespaces="availableNamespaces"
+ :user-namespace="defaultTargetNamespace"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index a803afeb901..e2fd608d9db 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -1,8 +1,17 @@
<script>
-import { GlIcon, GlBadge, GlFormInput, GlButton, GlLink } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlBadge,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+} from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
-import Select2Select from '~/vue_shared/components/select2_select.vue';
+import ImportGroupDropdown from '../../components/group_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
import { isProjectImportable, isIncompatible, getImportStatus } from '../utils';
@@ -10,10 +19,13 @@ import { isProjectImportable, isIncompatible, getImportStatus } from '../utils';
export default {
name: 'ProviderRepoTableRow',
components: {
- Select2Select,
+ ImportGroupDropdown,
ImportStatus,
GlFormInput,
GlButton,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
GlIcon,
GlBadge,
GlLink,
@@ -23,6 +35,10 @@ export default {
type: Object,
required: true,
},
+ userNamespace: {
+ type: String,
+ required: true,
+ },
availableNamespaces: {
type: Array,
required: true,
@@ -61,22 +77,6 @@ export default {
return this.ciCdOnly ? __('Connect') : __('Import');
},
- select2Options() {
- return {
- data: this.availableNamespaces,
- containerCssClass: 'import-namespace-select qa-project-namespace-select gl-w-auto',
- };
- },
-
- targetNamespaceSelect: {
- get() {
- return this.importTarget.targetNamespace;
- },
- set(value) {
- this.updateImportTarget({ targetNamespace: value });
- },
- },
-
newNameInput: {
get() {
return this.importTarget.newName;
@@ -118,7 +118,29 @@ export default {
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
<template v-else-if="isImportNotStarted">
<div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full">
- <select2-select v-model="targetNamespaceSelect" :options="select2Options" />
+ <import-group-dropdown
+ #default="{ namespaces }"
+ :text="importTarget.targetNamespace"
+ :namespaces="availableNamespaces"
+ >
+ <template v-if="namespaces.length">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in namespaces"
+ :key="ns"
+ data-qa-selector="target_group_dropdown_item"
+ :data-qa-group-name="ns"
+ @click="updateImportTarget({ targetNamespace: ns })"
+ >
+ {{ ns }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: ns })">{{
+ userNamespace
+ }}</gl-dropdown-item>
+ </import-group-dropdown>
<div
class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
>
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 6b7fe23ed60..110cc77b20d 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -38,7 +38,7 @@ export function initStoreFromElement(element) {
export function initPropsFromElement(element) {
return {
- providerTitle: element.dataset.providerTitle,
+ providerTitle: element.dataset.provider,
filterable: parseBoolean(element.dataset.filterable),
paginatable: parseBoolean(element.dataset.paginatable),
};
diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
index 83fd29a058e..93baa54956a 100644
--- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js
+++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
@@ -24,7 +24,6 @@ export default class IncidentsSettingsService {
createFlash({
message: `${ERROR_MSG} ${message}`,
- type: 'alert',
});
});
}
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index ec93980251b..1242493fb57 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -75,15 +75,6 @@ export default {
validProjectKey() {
return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
},
- showJiraVulnerabilitiesOptions() {
- return this.showJiraVulnerabilitiesIntegration;
- },
- showUltimateUpgrade() {
- return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration;
- },
- showPremiumUpgrade() {
- return !this.showJiraIssuesIntegration;
- },
},
created() {
eventHub.$on('validateForm', this.validateForm);
@@ -128,23 +119,30 @@ export default {
}}
</template>
</gl-form-checkbox>
- <jira-issue-creation-vulnerabilities
- v-if="enableJiraIssues"
- :project-key="projectKey"
- :initial-is-enabled="initialEnableJiraVulnerabilities"
- :initial-issue-type-id="initialVulnerabilitiesIssuetype"
- :show-full-feature="showJiraVulnerabilitiesOptions"
- data-testid="jira-for-vulnerabilities"
- @request-get-issue-types="getJiraIssueTypes"
- />
+ <template v-if="enableJiraIssues">
+ <jira-issue-creation-vulnerabilities
+ :project-key="projectKey"
+ :initial-is-enabled="initialEnableJiraVulnerabilities"
+ :initial-issue-type-id="initialVulnerabilitiesIssuetype"
+ :show-full-feature="showJiraVulnerabilitiesIntegration"
+ data-testid="jira-for-vulnerabilities"
+ @request-get-issue-types="getJiraIssueTypes"
+ />
+ <jira-upgrade-cta
+ v-if="!showJiraVulnerabilitiesIntegration"
+ class="gl-mt-2 gl-ml-6"
+ data-testid="ultimate-upgrade-cta"
+ show-ultimate-message
+ :upgrade-plan-path="upgradePlanPath"
+ />
+ </template>
</template>
<jira-upgrade-cta
- v-if="showUltimateUpgrade || showPremiumUpgrade"
+ v-else
class="gl-mt-2"
- :class="{ 'gl-ml-6': showUltimateUpgrade }"
+ data-testid="premium-upgrade-cta"
+ show-premium-message
:upgrade-plan-path="upgradePlanPath"
- :show-ultimate-message="showUltimateUpgrade"
- :show-premium-message="showPremiumUpgrade"
/>
</div>
</gl-form-group>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 84c8594c6b6..4aab1123af9 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlFormGroup,
GlModal,
GlDropdown,
GlDropdownItem,
@@ -12,16 +13,21 @@ import {
import { partition, isString } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
-import GroupSelect from '~/invite_members/components/group_select.vue';
-import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants';
import eventHub from '../event_hub';
+import {
+ responseMessageFromError,
+ responseMessageFromSuccess,
+} from '../utils/response_message_parser';
+import GroupSelect from './group_select.vue';
+import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
+ GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
@@ -79,9 +85,13 @@ export default {
selectedDate: undefined,
groupToBeSharedWith: {},
source: 'unknown',
+ invalidFeedbackMessage: '',
};
},
computed: {
+ validationState() {
+ return this.invalidFeedbackMessage === '' ? null : false;
+ },
isInviteGroup() {
return this.inviteeType === 'group';
},
@@ -142,6 +152,7 @@ export default {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
+ this.resetFields();
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite() {
@@ -150,7 +161,6 @@ export default {
} else {
this.submitInviteMembers();
}
- this.closeModal();
},
trackInvite() {
if (this.source === INVITE_MEMBERS_IN_COMMENT) {
@@ -158,12 +168,12 @@ export default {
tracking.event('comment_invite_success');
}
},
- cancelInvite() {
+ resetFields() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
- this.closeModal();
+ this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
@@ -175,9 +185,11 @@ export default {
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showToastMessageSuccess)
- .catch(this.showToastMessageError);
+ .catch(this.showInvalidFeedbackMessage);
},
submitInviteMembers() {
+ this.invalidFeedbackMessage = '';
+
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
@@ -196,10 +208,11 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
-
this.trackInvite();
- Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
+ Promise.all(promises)
+ .then(this.conditionallyShowToastSuccess)
+ .catch(this.showInvalidFeedbackMessage);
},
inviteByEmailPostData(usersToInviteByEmail) {
return {
@@ -224,13 +237,27 @@ export default {
group_access: this.selectedAccessLevel,
};
},
+ conditionallyShowToastSuccess(response) {
+ const message = responseMessageFromSuccess(response);
+
+ if (message === '') {
+ this.showToastMessageSuccess();
+
+ return;
+ }
+
+ this.invalidFeedbackMessage = message;
+ },
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
+ this.closeModal();
},
- showToastMessageError(error) {
- const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
-
- this.$toast.show(message, this.toastOptions);
+ showInvalidFeedbackMessage(response) {
+ this.invalidFeedbackMessage =
+ responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
+ },
+ handleMembersTokenSelectClear() {
+ this.invalidFeedbackMessage = '';
},
},
labels: {
@@ -267,8 +294,8 @@ export default {
accessLevel: s__('InviteMembersModal|Select a role'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
- toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
- readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`),
+ invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'),
+ readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
@@ -283,6 +310,7 @@ export default {
data-qa-selector="invite_members_modal_content"
:title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
+ @close="resetFields"
>
<div>
<p ref="introText">
@@ -293,15 +321,22 @@ export default {
</gl-sprintf>
</p>
- <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
- $options.labels[inviteeType].searchField
- }}</label>
- <div class="gl-mt-2">
+ <gl-form-group
+ class="gl-mt-2"
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ :description="$options.labels[inviteeType].placeHolder"
+ data-testid="members-form-group"
+ >
+ <label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
+ $options.labels[inviteeType].searchField
+ }}</label>
<members-token-select
v-if="!isInviteGroup"
v-model="newUsersToInvite"
+ :validation-state="validationState"
:aria-labelledby="$options.membersTokenSelectLabelId"
- :placeholder="$options.labels[inviteeType].placeHolder"
+ @clear="handleMembersTokenSelectClear"
/>
<group-select
v-if="isInviteGroup"
@@ -309,7 +344,7 @@ export default {
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
/>
- </div>
+ </gl-form-group>
<label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
@@ -364,15 +399,15 @@ export default {
<template #modal-footer>
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
- <gl-button ref="cancelButton" @click="cancelInvite">
+ <gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
- ref="inviteButton"
:disabled="inviteDisabled"
variant="success"
data-qa-selector="invite_button"
+ data-testid="invite-button"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index db6a7888786..7aece3b7bb4 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui';
+import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
@@ -10,6 +10,7 @@ export default {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
+ GlIcon,
GlSprintf,
},
props: {
@@ -22,6 +23,11 @@ export default {
type: String,
required: true,
},
+ validationState: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -84,6 +90,13 @@ export default {
this.hasBeenFocused = true;
},
+ handleTokenRemove() {
+ if (this.selectedTokens.length) {
+ return;
+ }
+
+ this.$emit('clear');
+ },
},
queryOptions: { exclude_internal: true, active: true },
i18n: {
@@ -95,19 +108,26 @@ export default {
<template>
<gl-token-selector
v-model="selectedTokens"
+ :state="validationState"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="emailIsValid"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
+ :text-input-attrs="{
+ 'data-testid': 'members-token-select-input',
+ 'data-qa-selector': 'members_token_select_input',
+ }"
@blur="handleBlur"
@text-input="handleTextInput"
@input="handleInput"
@focus="handleFocus"
+ @token-remove="handleTokenRemove"
>
<template #token-content="{ token }">
- <gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
+ <gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" />
+ <gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" />
{{ token.name }}
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 0c5538d5b86..83e6cac0ac0 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const SEARCH_DELAY = 200;
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
@@ -6,3 +8,7 @@ export const GROUP_FILTERS = {
ALL: 'all',
DESCENDANT_GROUPS: 'descendant_groups',
};
+
+export const API_MESSAGES = {
+ EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
+};
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
new file mode 100644
index 00000000000..b7bc9ea5652
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -0,0 +1,65 @@
+import { isString } from 'lodash';
+import { API_MESSAGES } from '~/invite_members/constants';
+
+function responseKeyedMessageParsed(keyedMessage) {
+ try {
+ const keys = Object.keys(keyedMessage);
+ const msg = keyedMessage[keys[0]];
+
+ if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
+ return '';
+ }
+ return msg;
+ } catch {
+ return '';
+ }
+}
+function responseMessageStringForMultiple(message) {
+ return message.includes(':');
+}
+function responseMessageStringFirstPart(message) {
+ return message.split(' and ')[0];
+}
+
+export function responseMessageFromError(response) {
+ if (!response?.response?.data) {
+ return '';
+ }
+
+ const {
+ response: { data },
+ } = response;
+
+ return (
+ data.error ||
+ data.message?.user?.[0] ||
+ data.message?.access_level?.[0] ||
+ data.message?.error ||
+ data.message ||
+ ''
+ );
+}
+
+export function responseMessageFromSuccess(response) {
+ if (!response?.[0]?.data) {
+ return '';
+ }
+
+ const { data } = response[0];
+
+ if (data.message && !data.message.user) {
+ const { message } = data;
+
+ if (isString(message)) {
+ if (responseMessageStringForMultiple(message)) {
+ return responseMessageStringFirstPart(message);
+ }
+
+ return message;
+ }
+
+ return responseKeyedMessageParsed(message);
+ }
+
+ return data.message || data.message?.user || data.error || '';
+}
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index c659dfef495..6e300831e00 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -36,7 +36,7 @@ export default {
default: null,
},
issuableType: {
- default: '',
+ default: 'issue',
},
emailsHelpPagePath: {
default: '',
@@ -78,7 +78,7 @@ export default {
} = await axios.put(this.resetPath);
this.email = newAddress;
} catch {
- this.$toast.show(__('There was an error when reseting email token.'), { type: 'error' });
+ this.$toast.show(__('There was an error when reseting email token.'));
}
},
cancelHandler() {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue
new file mode 100644
index 00000000000..9509399e91d
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { ISSUE_STATUS_SELECT_OPTIONS } from '../constants';
+
+export default {
+ name: 'StatusSelect',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ data() {
+ return {
+ status: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ return this.status?.text ?? this.$options.i18n.defaultDropdownText;
+ },
+ selectedValue() {
+ return this.status?.value;
+ },
+ },
+ methods: {
+ onDropdownItemClick(statusOption) {
+ // clear status if the currently checked status is clicked again
+ if (this.status?.value === statusOption.value) {
+ this.status = null;
+ } else {
+ this.status = statusOption;
+ }
+ },
+ },
+ i18n: {
+ dropdownTitle: __('Change status'),
+ defaultDropdownText: __('Select status'),
+ },
+ ISSUE_STATUS_SELECT_OPTIONS,
+};
+</script>
+<template>
+ <div>
+ <input type="hidden" name="update[state_event]" :value="selectedValue" />
+ <gl-dropdown :text="dropdownText" :title="$options.i18n.dropdownTitle" class="gl-w-full">
+ <gl-dropdown-item
+ v-for="statusOption in $options.ISSUE_STATUS_SELECT_OPTIONS"
+ :key="statusOption.value"
+ :is-checked="selectedValue === statusOption.value"
+ is-check-item
+ :title="statusOption.text"
+ @click="onDropdownItemClick(statusOption)"
+ >
+ {{ statusOption.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js
new file mode 100644
index 00000000000..ad15b25f9cf
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js
@@ -0,0 +1,17 @@
+import { __ } from '~/locale';
+
+export const ISSUE_STATUS_MODIFIERS = {
+ REOPEN: 'reopen',
+ CLOSE: 'close',
+};
+
+export const ISSUE_STATUS_SELECT_OPTIONS = [
+ {
+ value: ISSUE_STATUS_MODIFIERS.REOPEN,
+ text: __('Open'),
+ },
+ {
+ value: ISSUE_STATUS_MODIFIERS.CLOSE,
+ text: __('Closed'),
+ },
+];
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js
new file mode 100644
index 00000000000..43179a86d70
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import StatusSelect from './components/status_select.vue';
+
+export default function initIssueStatusSelect() {
+ const el = document.querySelector('.js-issue-status');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(StatusSelect);
+ },
+ });
+}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js
index 366a9a8a883..463e0e5837e 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import { difference, intersection, union } from 'lodash';
-import { deprecatedCreateFlash as Flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
export default {
init({ form, issues, prefixId } = {}) {
@@ -32,7 +32,9 @@ export default {
onFormSubmitFailure() {
this.form.find('[type="submit"]').enable();
- return new Flash(__('Issue update failed'));
+ return createFlash({
+ message: __('Issue update failed'),
+ });
},
/**
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js
index 97d50dde9f7..a9d4548f8cf 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js
@@ -2,11 +2,12 @@
import $ from 'jquery';
import { property } from 'lodash';
+
+import issueableEventHub from '~/issues_list/eventhub';
+import LabelsSelect from '~/labels_select';
+import MilestoneSelect from '~/milestone_select';
+import initIssueStatusSelect from './init_issue_status_select';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
-import issueStatusSelect from './issue_status_select';
-import issueableEventHub from './issues_list/eventhub';
-import LabelsSelect from './labels_select';
-import MilestoneSelect from './milestone_select';
import subscriptionSelect from './subscription_select';
const HIDDEN_CLASS = 'hidden';
@@ -29,7 +30,7 @@ export default class IssuableBulkUpdateSidebar {
this.$sidebar = $('.right-sidebar');
this.$sidebarInnerContainer = this.$sidebar.find('.issuable-sidebar');
this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
- this.$bulkEditSubmitBtn = $('.update-selected-issues');
+ this.$bulkEditSubmitBtn = $('.js-update-selected-issues');
this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle');
this.$otherFilters = $('.issues-other-filters');
this.$checkAllContainer = $('.check-all-holder');
@@ -56,7 +57,7 @@ export default class IssuableBulkUpdateSidebar {
initDropdowns() {
new LabelsSelect();
new MilestoneSelect();
- issueStatusSelect();
+ initIssueStatusSelect();
subscriptionSelect();
if (IS_EE) {
diff --git a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js
index 179c2b83c6c..179c2b83c6c 100644
--- a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js
index 4a688d819b0..b12ac776b4f 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from './locale';
+import { __ } from '~/locale';
export default function subscriptionSelect() {
$('.js-subscription-event').each((i, element) => {
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index a87d4f077cc..51b5237a339 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -9,19 +9,23 @@ export default class IssuableContext {
this.userSelect = new UsersSelect(currentUser);
this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search');
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // eslint-disable-next-line promise/no-nesting
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
- })
- .catch(() => {});
- })
- .catch(() => {});
+ const $select2 = $('select.select2');
+
+ if ($select2.length) {
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $select2.select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+ })
+ .catch(() => {});
+ })
+ .catch(() => {});
+ }
$('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() {
return $(this).submit();
diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/issuable_create/components/issuable_form.vue
index 3cbd5620063..c216a05bdb0 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_form.vue
+++ b/app/assets/javascripts/issuable_create/components/issuable_form.vue
@@ -72,16 +72,17 @@ export default {
:show-suggest-popover="true"
:textarea-value="issuableDescription"
>
- <textarea
- id="issuable-description"
- ref="textarea"
- slot="textarea"
- v-model="issuableDescription"
- dir="auto"
- class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- ></textarea>
+ <template #textarea>
+ <textarea
+ id="issuable-description"
+ ref="textarea"
+ v-model="issuableDescription"
+ dir="auto"
+ class="note-textarea qa-issuable-form-description rspec-issuable-form-description js-gfm-input js-autosize markdown-area"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ ></textarea>
+ </template>
</markdown-field>
</div>
</div>
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index cdeee68b762..5a57da292a0 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,4 +1,4 @@
-import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
+import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
export default class IssuableIndex {
constructor(pagePrefix = 'issuable_') {
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index a19c76cfe3f..87066a0a0b6 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -134,7 +134,7 @@ export default {
labelFilterParam: {
type: String,
required: false,
- default: null,
+ default: undefined,
},
isManualOrdering: {
type: Boolean,
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
index ca057094868..011db52cbe3 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -153,9 +153,9 @@ export default {
</template>
</issuable-discussion>
- <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)">
- <template #right-sidebar-items="sidebarProps">
- <slot name="right-sidebar-items" v-bind="sidebarProps"></slot>
+ <issuable-sidebar>
+ <template #right-sidebar-items="{ sidebarExpanded, toggleSidebar }">
+ <slot name="right-sidebar-items" v-bind="{ sidebarExpanded, toggleSidebar }"></slot>
</template>
</issuable-sidebar>
</div>
diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
index 8a159139af0..99dcccd12ed 100644
--- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
+++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue
@@ -2,15 +2,15 @@
import { GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
-
import { parseBoolean } from '~/lib/utils/common_utils';
+import { USER_COLLAPSED_GUTTER_COOKIE } from '../constants';
export default {
components: {
GlIcon,
},
data() {
- const userExpanded = !parseBoolean(Cookies.get('collapsed_gutter'));
+ const userExpanded = !parseBoolean(Cookies.get(USER_COLLAPSED_GUTTER_COOKIE));
// We're deliberately keeping two different props for sidebar status;
// 1. userExpanded reflects value based on cookie `collapsed_gutter`.
@@ -20,13 +20,6 @@ export default {
isExpanded: userExpanded ? bp.isDesktop() : userExpanded,
};
},
- watch: {
- isExpanded(expanded) {
- this.$emit('sidebar-toggle', {
- expanded,
- });
- },
- },
mounted() {
window.addEventListener('resize', this.handleWindowResize);
this.updatePageContainerClass();
@@ -49,11 +42,11 @@ export default {
this.updatePageContainerClass();
}
},
- handleToggleSidebarClick() {
+ toggleSidebar() {
this.isExpanded = !this.isExpanded;
this.userExpanded = this.isExpanded;
- Cookies.set('collapsed_gutter', !this.userExpanded);
+ Cookies.set(USER_COLLAPSED_GUTTER_COOKIE, !this.userExpanded);
this.updatePageContainerClass();
},
},
@@ -68,8 +61,9 @@ export default {
>
<button
class="toggle-right-sidebar-button js-toggle-right-sidebar-button w-100 gl-text-decoration-none! gl-display-flex gl-outline-0!"
+ data-testid="toggle-right-sidebar-button"
:title="__('Toggle sidebar')"
- @click="handleToggleSidebarClick"
+ @click="toggleSidebar"
>
<span v-if="isExpanded" class="collapse-text gl-flex-grow-1 gl-text-left">{{
__('Collapse sidebar')
@@ -83,7 +77,10 @@ export default {
/>
</button>
<div data-testid="sidebar-items" class="issuable-sidebar">
- <slot name="right-sidebar-items" v-bind="{ sidebarExpanded: isExpanded }"></slot>
+ <slot
+ name="right-sidebar-items"
+ v-bind="{ sidebarExpanded: isExpanded, toggleSidebar }"
+ ></slot>
</div>
</aside>
</template>
diff --git a/app/assets/javascripts/issuable_sidebar/constants.js b/app/assets/javascripts/issuable_sidebar/constants.js
new file mode 100644
index 00000000000..4f4b6341a1c
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/constants.js
@@ -0,0 +1 @@
+export const USER_COLLAPSED_GUTTER_COOKIE = 'collapsed_gutter';
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index f6eff8133a7..1e053d7daaa 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants';
import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
@@ -68,7 +68,9 @@ export default class Issue {
this.createMergeRequestDropdown.checkAbilityToCreateBranch();
}
} else {
- flash(issueFailMessage);
+ createFlash({
+ message: issueFailMessage,
+ });
}
}
@@ -102,6 +104,10 @@ export default class Issue {
$container.html(data.html);
}
})
- .catch(() => flash(__('Failed to load related branches')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to load related branches'),
+ }),
+ );
}
}
diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
index bb637dea033..938b90b3f7c 100644
--- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
+++ b/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql
@@ -1,6 +1,7 @@
query getAlert($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
+ id
alertManagementAlert {
iid
title
diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
index 9c28fdded21..ec8d8f32d8b 100644
--- a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
+++ b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql
@@ -1,5 +1,9 @@
mutation updateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) {
+ issuable: issue {
+ id
+ state
+ }
errors
}
}
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 08b04ebfdaf..b1deeaae0fc 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -1,11 +1,9 @@
-import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import axios from '../../lib/utils/axios_utils';
export default class Service {
constructor(endpoint) {
this.endpoint = `${endpoint}.json`;
this.realtimeEndpoint = `${endpoint}/realtime_changes`;
- registerCaptchaModalInterceptor(axios);
}
getData() {
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
deleted file mode 100644
index 2ede0837930..00000000000
--- a/app/assets/javascripts/issue_status_select.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from './locale';
-
-export default function issueStatusSelect() {
- $('.js-issue-status').each((i, el) => {
- const fieldName = $(el).data('fieldName');
- initDeprecatedJQueryDropdown($(el), {
- selectable: true,
- fieldName,
- toggleLabel(selected, element, instance) {
- let label = __('Author');
- const $item = instance.dropdown.find('.is-active');
- if ($item.length) {
- label = $item.text();
- }
- return label;
- },
- clicked(options) {
- return options.e.preventDefault();
- },
- id(obj, element) {
- return $(element).data('id');
- },
- });
- });
-}
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
index 51cad662ebf..b13a389b963 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -6,15 +6,11 @@ import {
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { toNumber, omit } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import {
- scrollToElement,
- urlParamsToObject,
- historyPushState,
- getParameterByName,
-} from '~/lib/utils/common_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { scrollToElement, historyPushState } from '~/lib/utils/common_utils';
+// eslint-disable-next-line import/no-deprecated
+import { setUrlParams, urlParamsToObject, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import initManualOrdering from '~/manual_ordering';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@@ -82,10 +78,7 @@ export default {
isBulkEditing: false,
issuables: [],
loading: false,
- page:
- getParameterByName('page', window.location.href) !== null
- ? toNumber(getParameterByName('page'))
- : 1,
+ page: getParameterByName('page') !== null ? toNumber(getParameterByName('page')) : 1,
selection: {},
totalItems: 0,
};
@@ -265,10 +258,13 @@ export default {
})
.catch(() => {
this.loading = false;
- return flash(__('An error occurred while loading issues'));
+ return createFlash({
+ message: __('An error occurred while loading issues'),
+ });
});
},
getQueryObject() {
+ // eslint-disable-next-line import/no-deprecated
return urlParamsToObject(window.location.search);
},
onPaginate(newPage) {
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
index 70d73aca925..07492b0046c 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -115,7 +115,7 @@ export default {
{{ timeEstimate }}
</span>
<weight-count
- class="gl-display-none gl-sm-display-inline-block gl-mr-3"
+ class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3"
:weight="issue.weight"
/>
<issue-health-status
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index dbf7717b248..6563094ef72 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -11,45 +11,47 @@ import {
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import createFlash from '~/flash';
+import { TYPE_USER } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
- API_PARAM,
CREATED_DESC,
i18n,
initialPageParams,
+ issuesCountSmartQueryBase,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
PARAM_SORT,
PARAM_STATE,
- RELATIVE_POSITION_DESC,
+ RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
- TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_EPIC,
TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
+ TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
- URL_PARAM,
urlSortParams,
} from '~/issues_list/constants';
import {
- convertToParams,
+ convertToApiParams,
convertToSearchQuery,
+ convertToUrlParams,
getDueDateValue,
getFilterTokens,
getSortKey,
getSortOptions,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
-import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
@@ -71,6 +73,10 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import eventHub from '../eventhub';
+import searchIterationsQuery from '../queries/search_iterations.query.graphql';
+import searchLabelsQuery from '../queries/search_labels.query.graphql';
+import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
+import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
@@ -95,9 +101,6 @@ export default {
autocompleteAwardEmojisPath: {
default: '',
},
- autocompleteUsersPath: {
- default: '',
- },
calendarPath: {
default: '',
},
@@ -119,6 +122,9 @@ export default {
hasIssueWeightsFeature: {
default: false,
},
+ hasIterationsFeature: {
+ default: false,
+ },
hasMultipleIssueAssigneesFeature: {
default: false,
},
@@ -140,15 +146,6 @@ export default {
newIssuePath: {
default: '',
},
- projectIterationsPath: {
- default: '',
- },
- projectLabelsPath: {
- default: '',
- },
- projectMilestonesPath: {
- default: '',
- },
projectPath: {
default: '',
},
@@ -176,26 +173,17 @@ export default {
showBulkEditSidebar: false,
sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey,
state: state || IssuableStates.Opened,
- totalIssues: 0,
};
},
apollo: {
issues: {
query: getIssuesQuery,
variables() {
- return {
- projectPath: this.projectPath,
- search: this.searchQuery,
- sort: this.sortKey,
- state: this.state,
- ...this.pageParams,
- ...this.apiFilterParams,
- };
+ return this.queryVariables;
},
- update: ({ project }) => project.issues.nodes,
+ update: ({ project }) => project?.issues.nodes ?? [],
result({ data }) {
- this.pageInfo = data.project.issues.pageInfo;
- this.totalIssues = data.project.issues.count;
+ this.pageInfo = data.project?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
@@ -206,8 +194,55 @@ export default {
},
debounce: 200,
},
+ countOpened: {
+ ...issuesCountSmartQueryBase,
+ variables() {
+ return {
+ ...this.queryVariables,
+ state: IssuableStates.Opened,
+ };
+ },
+ skip() {
+ return !this.hasProjectIssues;
+ },
+ },
+ countClosed: {
+ ...issuesCountSmartQueryBase,
+ variables() {
+ return {
+ ...this.queryVariables,
+ state: IssuableStates.Closed,
+ };
+ },
+ skip() {
+ return !this.hasProjectIssues;
+ },
+ },
+ countAll: {
+ ...issuesCountSmartQueryBase,
+ variables() {
+ return {
+ ...this.queryVariables,
+ state: IssuableStates.All,
+ };
+ },
+ skip() {
+ return !this.hasProjectIssues;
+ },
+ },
},
computed: {
+ queryVariables() {
+ return {
+ isSignedIn: this.isSignedIn,
+ projectPath: this.projectPath,
+ search: this.searchQuery,
+ sort: this.sortKey,
+ state: this.state,
+ ...this.pageParams,
+ ...this.apiFilterParams,
+ };
+ },
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
},
@@ -215,32 +250,30 @@ export default {
return this.showBulkEditSidebar || !this.issues.length;
},
isManualOrdering() {
- return this.sortKey === RELATIVE_POSITION_DESC;
+ return this.sortKey === RELATIVE_POSITION_ASC;
},
isOpenTab() {
return this.state === IssuableStates.Opened;
},
apiFilterParams() {
- return convertToParams(this.filterTokens, API_PARAM);
+ return convertToApiParams(this.filterTokens);
},
urlFilterParams() {
- return convertToParams(this.filterTokens, URL_PARAM);
+ return convertToUrlParams(this.filterTokens);
},
searchQuery() {
return convertToSearchQuery(this.filterTokens) || undefined;
},
searchTokens() {
- let preloadedAuthors = [];
+ const preloadedAuthors = [];
if (gon.current_user_id) {
- preloadedAuthors = [
- {
- id: gon.current_user_id,
- name: gon.current_user_fullname,
- username: gon.current_username,
- avatar_url: gon.current_user_avatar_url,
- },
- ];
+ preloadedAuthors.push({
+ id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
+ name: gon.current_user_fullname,
+ username: gon.current_username,
+ avatar_url: gon.current_user_avatar_url,
+ });
}
const tokens = [
@@ -252,6 +285,7 @@ export default {
dataType: 'user',
unique: true,
defaultAuthors: [],
+ operators: OPERATOR_IS_ONLY,
fetchAuthors: this.fetchUsers,
preloadedAuthors,
},
@@ -280,7 +314,7 @@ export default {
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
- defaultLabels: [],
+ defaultLabels: DEFAULT_NONE_ANY,
fetchLabels: this.fetchLabels,
},
];
@@ -310,7 +344,7 @@ export default {
});
}
- if (this.projectIterationsPath) {
+ if (this.hasIterationsFeature) {
tokens.push({
type: TOKEN_TYPE_ITERATION,
title: TOKEN_TITLE_ITERATION,
@@ -329,6 +363,7 @@ export default {
token: EpicToken,
unique: true,
idProperty: 'id',
+ useIdValue: true,
fetchEpics: this.fetchEpics,
});
}
@@ -346,37 +381,28 @@ export default {
return tokens;
},
showPaginationControls() {
- return this.issues.length > 0;
+ return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
sortOptions() {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
tabCounts() {
- return Object.values(IssuableStates).reduce(
- (acc, state) => ({
- ...acc,
- [state]: this.state === state ? this.totalIssues : undefined,
- }),
- {},
- );
+ return {
+ [IssuableStates.Opened]: this.countOpened,
+ [IssuableStates.Closed]: this.countClosed,
+ [IssuableStates.All]: this.countAll,
+ };
+ },
+ currentTabCount() {
+ return this.tabCounts[this.state] ?? 0;
},
urlParams() {
- const filterParams = {
- ...this.urlFilterParams,
- };
-
- if (filterParams.epic_id) {
- filterParams.epic_id = encodeURIComponent(filterParams.epic_id);
- } else if (filterParams['not[epic_id]']) {
- filterParams['not[epic_id]'] = encodeURIComponent(filterParams['not[epic_id]']);
- }
-
return {
due_date: this.dueDateFilter,
search: this.searchQuery,
+ sort: urlSortParams[this.sortKey],
state: this.state,
- ...urlSortParams[this.sortKey],
- ...filterParams,
+ ...this.urlFilterParams,
};
},
},
@@ -418,16 +444,42 @@ export default {
: epics.filter((epic) => epic.id === number);
},
fetchLabels(search) {
- return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search);
+ return this.$apollo
+ .query({
+ query: searchLabelsQuery,
+ variables: { projectPath: this.projectPath, search },
+ })
+ .then(({ data }) => data.project.labels.nodes);
},
fetchMilestones(search) {
- return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true);
+ return this.$apollo
+ .query({
+ query: searchMilestonesQuery,
+ variables: { projectPath: this.projectPath, search },
+ })
+ .then(({ data }) => data.project.milestones.nodes);
},
fetchIterations(search) {
- return axios.get(this.projectIterationsPath, { params: { search } });
+ const id = Number(search);
+ const variables =
+ !search || Number.isNaN(id)
+ ? { projectPath: this.projectPath, search }
+ : { projectPath: this.projectPath, id };
+
+ return this.$apollo
+ .query({
+ query: searchIterationsQuery,
+ variables,
+ })
+ .then(({ data }) => data.project.iterations.nodes);
},
fetchUsers(search) {
- return axios.get(this.autocompleteUsersPath, { params: { search } });
+ return this.$apollo
+ .query({
+ query: searchUsersQuery,
+ variables: { projectPath: this.projectPath, search },
+ })
+ .then(({ data }) => data.project.projectMembers.nodes.map((member) => member.user));
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
@@ -450,7 +502,9 @@ export default {
},
async handleBulkUpdateClick() {
if (!this.hasInitBulkEdit) {
- const initBulkUpdateSidebar = await import('~/issuable_init_bulk_update_sidebar');
+ const initBulkUpdateSidebar = await import(
+ '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'
+ );
initBulkUpdateSidebar.default.init('issuable_');
const usersSelect = await import('~/users_select');
@@ -469,6 +523,7 @@ export default {
this.state = state;
},
handleFilter(filter) {
+ this.pageParams = initialPageParams;
this.filterTokens = filter;
},
handleNextPage() {
@@ -581,7 +636,7 @@ export default {
v-if="isSignedIn"
class="gl-md-mr-3"
:export-csv-path="exportCsvPathWithQuery"
- :issuable-count="totalIssues"
+ :issuable-count="currentTabCount"
/>
<gl-button
v-if="canBulkUpdate"
@@ -609,7 +664,7 @@ export default {
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="$options.i18n.relatedMergeRequests"
- data-testid="issuable-mr"
+ data-testid="merge-requests"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
@@ -617,7 +672,7 @@ export default {
<li
v-if="issuable.upvotes"
v-gl-tooltip
- class="gl-display-none gl-sm-display-block"
+ class="issuable-upvotes gl-display-none gl-sm-display-block"
:title="$options.i18n.upvotes"
data-testid="issuable-upvotes"
>
@@ -627,7 +682,7 @@ export default {
<li
v-if="issuable.downvotes"
v-gl-tooltip
- class="gl-display-none gl-sm-display-block"
+ class="issuable-downvotes gl-display-none gl-sm-display-block"
:title="$options.i18n.downvotes"
data-testid="issuable-downvotes"
>
@@ -635,9 +690,10 @@ export default {
{{ issuable.downvotes }}
</li>
<blocking-issues-count
- class="gl-display-none gl-sm-display-block"
- :blocking-issues-count="issuable.blockedByCount"
+ class="blocking-issues gl-display-none gl-sm-display-block"
+ :blocking-issues-count="issuable.blockingCount"
:is-list-item="true"
+ data-testid="blocking-issues"
/>
</template>
@@ -692,7 +748,7 @@ export default {
<csv-import-export-buttons
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
- :issuable-count="totalIssues"
+ :issuable-count="currentTabCount"
/>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index 76006f9081d..d94d4b9a19a 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -1,3 +1,5 @@
+import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql';
+import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import {
FILTER_ANY,
@@ -68,6 +70,7 @@ export const i18n = {
confidentialYes: __('Yes'),
downvotes: __('Downvotes'),
editIssues: __('Edit issues'),
+ errorFetchingCounts: __('An error occurred while getting issue counts'),
errorFetchingIssues: __('An error occurred while loading issues'),
jiraIntegrationMessage: s__(
'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
@@ -94,7 +97,7 @@ export const i18n = {
relatedMergeRequests: __('Related merge requests'),
reorderError: __('An error occurred while reordering issues.'),
rssLabel: __('Subscribe to RSS feed'),
- searchPlaceholder: __('Search or filter results…'),
+ searchPlaceholder: __('Search or filter results...'),
upvotes: __('Upvotes'),
};
@@ -128,21 +131,21 @@ export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
export const DUE_DATE_ASC = 'DUE_DATE_ASC';
export const DUE_DATE_DESC = 'DUE_DATE_DESC';
+export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
export const POPULARITY_ASC = 'POPULARITY_ASC';
export const POPULARITY_DESC = 'POPULARITY_DESC';
+export const PRIORITY_ASC = 'PRIORITY_ASC';
export const PRIORITY_DESC = 'PRIORITY_DESC';
-export const RELATIVE_POSITION_DESC = 'RELATIVE_POSITION_DESC';
+export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
-const SORT_ASC = 'asc';
-const SORT_DESC = 'desc';
-
+const PRIORITY_ASC_SORT = 'priority_asc';
const CREATED_DATE_SORT = 'created_date';
const CREATED_ASC_SORT = 'created_asc';
const UPDATED_DESC_SORT = 'updated_desc';
@@ -150,129 +153,30 @@ const UPDATED_ASC_SORT = 'updated_asc';
const MILESTONE_SORT = 'milestone';
const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc';
const DUE_DATE_DESC_SORT = 'due_date_desc';
+const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
const POPULARITY_ASC_SORT = 'popularity_asc';
const WEIGHT_DESC_SORT = 'weight_desc';
const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
-const BLOCKING_ISSUES = 'blocking_issues';
-
-export const apiSortParams = {
- [PRIORITY_DESC]: {
- order_by: PRIORITY,
- sort: SORT_DESC,
- },
- [CREATED_ASC]: {
- order_by: CREATED_AT,
- sort: SORT_ASC,
- },
- [CREATED_DESC]: {
- order_by: CREATED_AT,
- sort: SORT_DESC,
- },
- [UPDATED_ASC]: {
- order_by: UPDATED_AT,
- sort: SORT_ASC,
- },
- [UPDATED_DESC]: {
- order_by: UPDATED_AT,
- sort: SORT_DESC,
- },
- [MILESTONE_DUE_ASC]: {
- order_by: MILESTONE_DUE,
- sort: SORT_ASC,
- },
- [MILESTONE_DUE_DESC]: {
- order_by: MILESTONE_DUE,
- sort: SORT_DESC,
- },
- [DUE_DATE_ASC]: {
- order_by: DUE_DATE,
- sort: SORT_ASC,
- },
- [DUE_DATE_DESC]: {
- order_by: DUE_DATE,
- sort: SORT_DESC,
- },
- [POPULARITY_ASC]: {
- order_by: POPULARITY,
- sort: SORT_ASC,
- },
- [POPULARITY_DESC]: {
- order_by: POPULARITY,
- sort: SORT_DESC,
- },
- [LABEL_PRIORITY_DESC]: {
- order_by: LABEL_PRIORITY,
- sort: SORT_DESC,
- },
- [RELATIVE_POSITION_DESC]: {
- order_by: RELATIVE_POSITION,
- per_page: 100,
- sort: SORT_ASC,
- },
- [WEIGHT_ASC]: {
- order_by: WEIGHT,
- sort: SORT_ASC,
- },
- [WEIGHT_DESC]: {
- order_by: WEIGHT,
- sort: SORT_DESC,
- },
- [BLOCKING_ISSUES_DESC]: {
- order_by: BLOCKING_ISSUES,
- sort: SORT_DESC,
- },
-};
export const urlSortParams = {
- [PRIORITY_DESC]: {
- sort: PRIORITY,
- },
- [CREATED_ASC]: {
- sort: CREATED_ASC_SORT,
- },
- [CREATED_DESC]: {
- sort: CREATED_DATE_SORT,
- },
- [UPDATED_ASC]: {
- sort: UPDATED_ASC_SORT,
- },
- [UPDATED_DESC]: {
- sort: UPDATED_DESC_SORT,
- },
- [MILESTONE_DUE_ASC]: {
- sort: MILESTONE_SORT,
- },
- [MILESTONE_DUE_DESC]: {
- sort: MILESTONE_DUE_DESC_SORT,
- },
- [DUE_DATE_ASC]: {
- sort: DUE_DATE,
- },
- [DUE_DATE_DESC]: {
- sort: DUE_DATE_DESC_SORT,
- },
- [POPULARITY_ASC]: {
- sort: POPULARITY_ASC_SORT,
- },
- [POPULARITY_DESC]: {
- sort: POPULARITY,
- },
- [LABEL_PRIORITY_DESC]: {
- sort: LABEL_PRIORITY,
- },
- [RELATIVE_POSITION_DESC]: {
- sort: RELATIVE_POSITION,
- per_page: 100,
- },
- [WEIGHT_ASC]: {
- sort: WEIGHT,
- },
- [WEIGHT_DESC]: {
- sort: WEIGHT_DESC_SORT,
- },
- [BLOCKING_ISSUES_DESC]: {
- sort: BLOCKING_ISSUES_DESC_SORT,
- },
+ [PRIORITY_ASC]: PRIORITY_ASC_SORT,
+ [PRIORITY_DESC]: PRIORITY,
+ [CREATED_ASC]: CREATED_ASC_SORT,
+ [CREATED_DESC]: CREATED_DATE_SORT,
+ [UPDATED_ASC]: UPDATED_ASC_SORT,
+ [UPDATED_DESC]: UPDATED_DESC_SORT,
+ [MILESTONE_DUE_ASC]: MILESTONE_SORT,
+ [MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT,
+ [DUE_DATE_ASC]: DUE_DATE,
+ [DUE_DATE_DESC]: DUE_DATE_DESC_SORT,
+ [POPULARITY_ASC]: POPULARITY_ASC_SORT,
+ [POPULARITY_DESC]: POPULARITY,
+ [LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT,
+ [LABEL_PRIORITY_DESC]: LABEL_PRIORITY,
+ [RELATIVE_POSITION_ASC]: RELATIVE_POSITION,
+ [WEIGHT_ASC]: WEIGHT,
+ [WEIGHT_DESC]: WEIGHT_DESC_SORT,
+ [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
};
export const MAX_LIST_SIZE = 10;
@@ -297,12 +201,7 @@ export const TOKEN_TYPE_WEIGHT = 'weight';
export const filters = {
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'author_username',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[author_username]',
- },
+ [NORMAL_FILTER]: 'authorUsername',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -315,13 +214,8 @@ export const filters = {
},
[TOKEN_TYPE_ASSIGNEE]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'assignee_username',
- [SPECIAL_FILTER]: 'assignee_id',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[assignee_username]',
- },
+ [NORMAL_FILTER]: 'assigneeUsernames',
+ [SPECIAL_FILTER]: 'assigneeId',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -336,12 +230,7 @@ export const filters = {
},
[TOKEN_TYPE_MILESTONE]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'milestone',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[milestone]',
- },
+ [NORMAL_FILTER]: 'milestoneTitle',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -354,16 +243,13 @@ export const filters = {
},
[TOKEN_TYPE_LABEL]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'labels',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[labels]',
- },
+ [NORMAL_FILTER]: 'labelName',
+ [SPECIAL_FILTER]: 'labelName',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'label_name[]',
+ [SPECIAL_FILTER]: 'label_name[]',
},
[OPERATOR_IS_NOT]: {
[NORMAL_FILTER]: 'not[label_name][]',
@@ -372,10 +258,8 @@ export const filters = {
},
[TOKEN_TYPE_MY_REACTION]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'my_reaction_emoji',
- [SPECIAL_FILTER]: 'my_reaction_emoji',
- },
+ [NORMAL_FILTER]: 'myReactionEmoji',
+ [SPECIAL_FILTER]: 'myReactionEmoji',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -386,9 +270,7 @@ export const filters = {
},
[TOKEN_TYPE_CONFIDENTIAL]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'confidential',
- },
+ [NORMAL_FILTER]: 'confidential',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -398,33 +280,23 @@ export const filters = {
},
[TOKEN_TYPE_ITERATION]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'iteration_title',
- [SPECIAL_FILTER]: 'iteration_id',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[iteration_title]',
- },
+ [NORMAL_FILTER]: 'iterationId',
+ [SPECIAL_FILTER]: 'iterationWildcardId',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
- [NORMAL_FILTER]: 'iteration_title',
+ [NORMAL_FILTER]: 'iteration_id',
[SPECIAL_FILTER]: 'iteration_id',
},
[OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[iteration_title]',
+ [NORMAL_FILTER]: 'not[iteration_id]',
},
},
},
[TOKEN_TYPE_EPIC]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'epic_id',
- [SPECIAL_FILTER]: 'epic_id',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[epic_id]',
- },
+ [NORMAL_FILTER]: 'epicId',
+ [SPECIAL_FILTER]: 'epicId',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -438,13 +310,8 @@ export const filters = {
},
[TOKEN_TYPE_WEIGHT]: {
[API_PARAM]: {
- [OPERATOR_IS]: {
- [NORMAL_FILTER]: 'weight',
- [SPECIAL_FILTER]: 'weight',
- },
- [OPERATOR_IS_NOT]: {
- [NORMAL_FILTER]: 'not[weight]',
- },
+ [NORMAL_FILTER]: 'weight',
+ [SPECIAL_FILTER]: 'weight',
},
[URL_PARAM]: {
[OPERATOR_IS]: {
@@ -457,3 +324,15 @@ export const filters = {
},
},
};
+
+export const issuesCountSmartQueryBase = {
+ query: getIssuesCountQuery,
+ context: {
+ isSingleRequest: true,
+ },
+ update: ({ project }) => project?.issues.count,
+ error(error) {
+ createFlash({ message: i18n.errorFetchingCounts, captureError: true, error });
+ },
+ debounce: 200,
+};
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 97b9a9a115d..71ceb9bef55 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { IssuableType } from '~/issue_show/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
@@ -82,7 +81,6 @@ export function mountIssuesListApp() {
const {
autocompleteAwardEmojisPath,
- autocompleteUsersPath,
calendarPath,
canBulkUpdate,
canEdit,
@@ -95,6 +93,7 @@ export function mountIssuesListApp() {
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
+ hasIterationsFeature,
hasMultipleIssueAssigneesFeature,
hasProjectIssues,
importCsvIssuesPath,
@@ -106,9 +105,6 @@ export function mountIssuesListApp() {
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
- projectIterationsPath,
- projectLabelsPath,
- projectMilestonesPath,
projectPath,
quickActionsHelpPath,
resetPath,
@@ -122,7 +118,6 @@ export function mountIssuesListApp() {
apolloProvider,
provide: {
autocompleteAwardEmojisPath,
- autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
@@ -130,15 +125,13 @@ export function mountIssuesListApp() {
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
hasProjectIssues: parseBoolean(hasProjectIssues),
isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath,
newIssuePath,
- projectIterationsPath,
- projectLabelsPath,
- projectMilestonesPath,
projectPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
@@ -156,7 +149,6 @@ export function mountIssuesListApp() {
// For IssuableByEmail component
emailsHelpPagePath,
initialEmail,
- issuableType: IssuableType.Issue,
markdownHelpPath,
quickActionsHelpPath,
resetPath,
diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
index afd53084ca0..124190915c0 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
@@ -2,6 +2,7 @@
#import "./issue.fragment.graphql"
query getProjectIssues(
+ $isSignedIn: Boolean = false
$projectPath: ID!
$search: String
$sort: IssueSort
@@ -33,7 +34,6 @@ query getProjectIssues(
first: $firstPageSize
last: $lastPageSize
) {
- count
pageInfo {
...PageInfo
}
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql
new file mode 100644
index 00000000000..a1742859640
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql
@@ -0,0 +1,26 @@
+query getProjectIssuesCount(
+ $projectPath: ID!
+ $search: String
+ $state: IssuableState
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $not: NegatedIssueFilterInput
+) {
+ project(fullPath: $projectPath) {
+ issues(
+ search: $search
+ state: $state
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ not: $not
+ ) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
index de30d8b4bf6..f7ebf64ffb8 100644
--- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
@@ -11,7 +11,7 @@ fragment IssueFragment on Issue {
title
updatedAt
upvotes
- userDiscussionsCount
+ userDiscussionsCount @include(if: $isSignedIn)
webUrl
assignees {
nodes {
diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
new file mode 100644
index 00000000000..11d9dcea573
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
@@ -0,0 +1,10 @@
+query searchIterations($projectPath: ID!, $search: String, $id: ID) {
+ project(fullPath: $projectPath) {
+ iterations(title: $search, id: $id) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
new file mode 100644
index 00000000000..de884e1221c
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
@@ -0,0 +1,12 @@
+query searchLabels($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ labels(searchTerm: $search, includeAncestorGroups: true) {
+ nodes {
+ id
+ color
+ textColor
+ title
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
new file mode 100644
index 00000000000..91f74fd220b
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
@@ -0,0 +1,10 @@
+query searchMilestones($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ milestones(searchTitle: $search, includeAncestors: true) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
new file mode 100644
index 00000000000..953157cfe3a
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
@@ -0,0 +1,14 @@
+query searchUsers($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ projectMembers(search: $search) {
+ nodes {
+ user {
+ id
+ avatarUrl
+ name
+ username
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js
index b5ec44198da..49f937cc453 100644
--- a/app/assets/javascripts/issues_list/utils.js
+++ b/app/assets/javascripts/issues_list/utils.js
@@ -1,4 +1,5 @@
import {
+ API_PARAM,
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
@@ -6,29 +7,36 @@ import {
DUE_DATE_DESC,
DUE_DATE_VALUES,
filters,
+ LABEL_PRIORITY_ASC,
LABEL_PRIORITY_DESC,
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
POPULARITY_ASC,
POPULARITY_DESC,
+ PRIORITY_ASC,
PRIORITY_DESC,
- RELATIVE_POSITION_DESC,
+ RELATIVE_POSITION_ASC,
SPECIAL_FILTER,
SPECIAL_FILTER_VALUES,
TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_ITERATION,
UPDATED_ASC,
UPDATED_DESC,
+ URL_PARAM,
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
} from '~/issues_list/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
-import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATOR_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
export const getSortKey = (sort) =>
- Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort);
+ Object.keys(urlSortParams).find((key) => urlSortParams[key] === sort);
export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
@@ -38,7 +46,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 1,
title: __('Priority'),
sortDirection: {
- ascending: PRIORITY_DESC,
+ ascending: PRIORITY_ASC,
descending: PRIORITY_DESC,
},
},
@@ -86,7 +94,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 7,
title: __('Label priority'),
sortDirection: {
- ascending: LABEL_PRIORITY_DESC,
+ ascending: LABEL_PRIORITY_ASC,
descending: LABEL_PRIORITY_DESC,
},
},
@@ -94,8 +102,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: 8,
title: __('Manual'),
sortDirection: {
- ascending: RELATIVE_POSITION_DESC,
- descending: RELATIVE_POSITION_DESC,
+ ascending: RELATIVE_POSITION_ASC,
+ descending: RELATIVE_POSITION_ASC,
},
},
];
@@ -128,7 +136,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
const tokenTypes = Object.keys(filters);
const getUrlParams = (tokenType) =>
- Object.values(filters[tokenType].urlParam).flatMap((filterObj) => Object.values(filterObj));
+ Object.values(filters[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj));
const urlParamKeys = tokenTypes.flatMap(getUrlParams);
@@ -136,7 +144,7 @@ const getTokenTypeFromUrlParamKey = (urlParamKey) =>
tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey));
const getOperatorFromUrlParamKey = (tokenType, urlParamKey) =>
- Object.entries(filters[tokenType].urlParam).find(([, filterObj]) =>
+ Object.entries(filters[tokenType][URL_PARAM]).find(([, filterObj]) =>
Object.values(filterObj).includes(urlParamKey),
)[0];
@@ -178,12 +186,36 @@ const getFilterType = (data, tokenType = '') =>
? SPECIAL_FILTER
: NORMAL_FILTER;
-export const convertToParams = (filterTokens, paramType) =>
+const isIterationSpecialValue = (tokenType, value) =>
+ tokenType === TOKEN_TYPE_ITERATION && SPECIAL_FILTER_VALUES.includes(value);
+
+export const convertToApiParams = (filterTokens) => {
+ const params = {};
+ const not = {};
+
+ filterTokens
+ .filter((token) => token.type !== FILTERED_SEARCH_TERM)
+ .forEach((token) => {
+ const filterType = getFilterType(token.value.data, token.type);
+ const field = filters[token.type][API_PARAM][filterType];
+ const obj = token.value.operator === OPERATOR_IS_NOT ? not : params;
+ const data = isIterationSpecialValue(token.type, token.value.data)
+ ? token.value.data.toUpperCase()
+ : token.value.data;
+ Object.assign(obj, {
+ [field]: obj[field] ? [obj[field], data].flat() : data,
+ });
+ });
+
+ return Object.keys(not).length ? Object.assign(params, { not }) : params;
+};
+
+export const convertToUrlParams = (filterTokens) =>
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const filterType = getFilterType(token.value.data, token.type);
- const param = filters[token.type][paramType][token.value.operator]?.[filterType];
+ const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType];
return Object.assign(acc, {
[param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data,
});
diff --git a/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
new file mode 100644
index 00000000000..c1f57be7f97
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/branches/components/project_dropdown.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { PROJECTS_PER_PAGE } from '../constants';
+import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
+
+export default {
+ PROJECTS_PER_PAGE,
+ projectQueryPageInfo: {
+ endCursor: '',
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ },
+ props: {
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ initialProjectsLoading: true,
+ projectSearchQuery: '',
+ };
+ },
+ apollo: {
+ projects: {
+ query: getProjectsQuery,
+ variables() {
+ return {
+ search: this.projectSearchQuery,
+ first: this.$options.PROJECTS_PER_PAGE,
+ after: this.$options.projectQueryPageInfo.endCursor,
+ searchNamespaces: true,
+ sort: 'similarity',
+ };
+ },
+ update(data) {
+ return data?.projects?.nodes.filter((project) => !project.repository.empty) ?? [];
+ },
+ result() {
+ this.initialProjectsLoading = false;
+ },
+ error() {
+ this.onError({ message: __('Failed to load projects') });
+ },
+ },
+ },
+ computed: {
+ projectsLoading() {
+ return Boolean(this.$apollo.queries.projects.loading);
+ },
+ projectDropdownText() {
+ return this.selectedProject?.nameWithNamespace || __('Select a project');
+ },
+ },
+ methods: {
+ async onProjectSelect(project) {
+ this.$emit('change', project);
+ },
+ onError({ message } = {}) {
+ this.$emit('error', { message });
+ },
+ isProjectSelected(project) {
+ return project.id === this.selectedProject?.id;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown :text="projectDropdownText" :loading="initialProjectsLoading">
+ <template #header>
+ <gl-search-box-by-type v-model.trim="projectSearchQuery" :debounce="250" />
+ </template>
+
+ <gl-loading-icon v-show="projectsLoading" />
+ <template v-if="!projectsLoading">
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ is-check-item
+ :is-checked="isProjectSelected(project)"
+ @click="onProjectSelect(project)"
+ >
+ {{ project.nameWithNamespace }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue
new file mode 100644
index 00000000000..0e2d8821f36
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { BRANCHES_PER_PAGE } from '../constants';
+import getProjectQuery from '../graphql/queries/get_project.query.graphql';
+
+export default {
+ BRANCHES_PER_PAGE,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ },
+ props: {
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ selectedBranchName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ sourceBranchSearchQuery: '',
+ initialSourceBranchNamesLoading: false,
+ sourceBranchNamesLoading: false,
+ sourceBranchNames: [],
+ };
+ },
+ computed: {
+ hasSelectedProject() {
+ return Boolean(this.selectedProject);
+ },
+ hasSelectedSourceBranch() {
+ return Boolean(this.selectedBranchName);
+ },
+ branchDropdownText() {
+ return this.selectedBranchName || __('Select a branch');
+ },
+ },
+ watch: {
+ selectedProject: {
+ immediate: true,
+ async handler(selectedProject) {
+ if (!selectedProject) return;
+
+ this.initialSourceBranchNamesLoading = true;
+ await this.fetchSourceBranchNames({ projectPath: selectedProject.fullPath });
+ this.initialSourceBranchNamesLoading = false;
+ },
+ },
+ },
+ methods: {
+ onSourceBranchSelect(branchName) {
+ this.$emit('change', branchName);
+ },
+ onSourceBranchSearchQuery(branchSearchQuery) {
+ this.branchSearchQuery = branchSearchQuery;
+ this.fetchSourceBranchNames({
+ projectPath: this.selectedProject.fullPath,
+ searchPattern: this.branchSearchQuery,
+ });
+ },
+ onError({ message } = {}) {
+ this.$emit('error', { message });
+ },
+ async fetchSourceBranchNames({ projectPath, searchPattern } = {}) {
+ this.sourceBranchNamesLoading = true;
+ try {
+ const { data } = await this.$apollo.query({
+ query: getProjectQuery,
+ variables: {
+ projectPath,
+ branchNamesLimit: this.$options.BRANCHES_PER_PAGE,
+ branchNamesOffset: 0,
+ branchNamesSearchPattern: searchPattern ? `*${searchPattern}*` : '*',
+ },
+ });
+
+ const { branchNames, rootRef } = data?.project.repository || {};
+ this.sourceBranchNames = branchNames || [];
+
+ // Use root ref as the default selection
+ if (rootRef && !this.hasSelectedSourceBranch) {
+ this.onSourceBranchSelect(rootRef);
+ }
+ } catch (err) {
+ this.onError({
+ message: __('Something went wrong while fetching source branches.'),
+ });
+ } finally {
+ this.sourceBranchNamesLoading = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :text="branchDropdownText"
+ :loading="initialSourceBranchNamesLoading"
+ :disabled="!hasSelectedProject"
+ :class="{ 'gl-font-monospace': hasSelectedSourceBranch }"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ :debounce="250"
+ :value="sourceBranchSearchQuery"
+ @input="onSourceBranchSearchQuery"
+ />
+ </template>
+
+ <gl-loading-icon v-show="sourceBranchNamesLoading" />
+ <template v-if="!sourceBranchNamesLoading">
+ <gl-dropdown-item
+ v-for="branchName in sourceBranchNames"
+ v-show="!sourceBranchNamesLoading"
+ :key="branchName"
+ :is-checked="branchName === selectedBranchName"
+ is-check-item
+ class="gl-font-monospace"
+ @click="onSourceBranchSelect(branchName)"
+ >
+ {{ branchName }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/jira_connect/branches/constants.js b/app/assets/javascripts/jira_connect/branches/constants.js
new file mode 100644
index 00000000000..987c8d356b4
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/branches/constants.js
@@ -0,0 +1,2 @@
+export const BRANCHES_PER_PAGE = 20;
+export const PROJECTS_PER_PAGE = 20;
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql
new file mode 100644
index 00000000000..f3428e816d7
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql
@@ -0,0 +1,17 @@
+query getProject(
+ $projectPath: ID!
+ $branchNamesLimit: Int!
+ $branchNamesOffset: Int!
+ $branchNamesSearchPattern: String!
+) {
+ project(fullPath: $projectPath) {
+ repository {
+ branchNames(
+ limit: $branchNamesLimit
+ offset: $branchNamesOffset
+ searchPattern: $branchNamesSearchPattern
+ )
+ rootRef
+ }
+ }
+}
diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
new file mode 100644
index 00000000000..e768154e210
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_projects.query.graphql
@@ -0,0 +1,34 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getProjects(
+ $search: String!
+ $after: String = ""
+ $first: Int!
+ $searchNamespaces: Boolean = false
+ $sort: String
+ $membership: Boolean = true
+) {
+ projects(
+ search: $search
+ after: $after
+ first: $first
+ membership: $membership
+ searchNamespaces: $searchNamespaces
+ sort: $sort
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ fullPath
+ avatarUrl
+ path
+ repository {
+ empty
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue
index d764f778a9d..55233bb6326 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list.vue
@@ -89,6 +89,7 @@ export default {
debounce="500"
:placeholder="__('Search by name')"
:is-loading="isLoadingMore"
+ :value="searchTerm"
@input="onGroupSearch"
/>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index e7816f6d187..1b6e365fdb2 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -310,7 +310,7 @@ export default {
>
<gl-search-box-by-type v-model.trim="searchTerm" />
- <gl-loading-icon v-if="isFetching" />
+ <gl-loading-icon v-if="isFetching" size="sm" />
<gl-dropdown-item
v-for="user in users"
@@ -328,7 +328,7 @@ export default {
</template>
</gl-table>
- <gl-loading-icon v-if="isInitialLoadingState" />
+ <gl-loading-icon v-if="isInitialLoadingState" size="sm" />
<gl-button
v-if="hasMoreUsers"
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue
index 35b16d73cc7..e31c13f40b0 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/empty_state.vue
@@ -35,11 +35,6 @@ export default {
required: false,
default: false,
},
- variablesSettingsUrl: {
- type: String,
- required: false,
- default: null,
- },
action: {
type: Object,
required: false,
@@ -75,11 +70,7 @@ export default {
<p v-if="content" data-testid="job-empty-state-content">{{ content }}</p>
</div>
- <manual-variables-form
- v-if="shouldRenderManualVariables"
- :action="action"
- :variables-settings-url="variablesSettingsUrl"
- />
+ <manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
<div class="text-content">
<div v-if="action && !shouldRenderManualVariables" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index be95001a396..fa9ee56c049 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -50,11 +50,6 @@ export default {
required: false,
default: null,
},
- variablesSettingsUrl: {
- type: String,
- required: false,
- default: null,
- },
deploymentHelpUrl: {
type: String,
required: false,
@@ -315,7 +310,6 @@ export default {
:action="emptyStateAction"
:playable="job.playable"
:scheduled="job.scheduled"
- :variables-settings-url="variablesSettingsUrl"
/>
<!-- EO empty state -->
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index 55cdfb691f4..c0d5fac0e8d 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -1,4 +1,6 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
import LogLine from './line.vue';
import LogLineHeader from './line_header.vue';
@@ -7,7 +9,9 @@ export default {
components: {
LogLine,
LogLineHeader,
+ CollapsibleLogSection: () => import('./collapsible_section.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
props: {
section: {
type: Object,
@@ -22,6 +26,9 @@ export default {
badgeDuration() {
return this.section.line && this.section.line.section_duration;
},
+ infinitelyCollapsibleSectionsFlag() {
+ return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
+ },
},
methods: {
handleOnClickCollapsibleLine(section) {
@@ -40,12 +47,26 @@ export default {
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
<template v-if="!section.isClosed">
- <log-line
- v-for="line in section.lines"
- :key="line.offset"
- :line="line"
- :path="traceEndpoint"
- />
+ <template v-if="infinitelyCollapsibleSectionsFlag">
+ <template v-for="line in section.lines">
+ <collapsible-log-section
+ v-if="line.isHeader"
+ :key="line.line.offset"
+ :section="line"
+ :trace-endpoint="traceEndpoint"
+ @onClickCollapsibleLine="handleOnClickCollapsibleLine"
+ />
+ <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" />
+ </template>
+ </template>
+ <template v-else>
+ <log-line
+ v-for="line in section.lines"
+ :key="line.offset"
+ :line="line"
+ :path="traceEndpoint"
+ />
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index 7ca9154d2fe..c8ceac2c7ff 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -1,4 +1,6 @@
<script>
+import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
+
export default {
functional: true,
props: {
@@ -14,7 +16,9 @@ export default {
render(h, { props }) {
const { lineNumber, path } = props;
- const parsedLineNumber = lineNumber + 1;
+ const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]
+ ? lineNumber
+ : lineNumber + 1;
const lineId = `L${parsedLineNumber}`;
const lineHref = `${path}#${lineId}`;
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index d45012d2023..269551ff9aa 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -1,14 +1,16 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { mapActions } from 'vuex';
-import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
export default {
name: 'ManualVariablesForm',
components: {
GlButton,
+ GlLink,
+ GlSprintf,
},
props: {
action: {
@@ -24,11 +26,6 @@ export default {
);
},
},
- variablesSettingsUrl: {
- type: String,
- required: true,
- default: '',
- },
},
inputTypes: {
key: 'key',
@@ -37,6 +34,9 @@ export default {
i18n: {
keyPlaceholder: s__('CiVariables|Input variable key'),
valuePlaceholder: s__('CiVariables|Input variable value'),
+ formHelpText: s__(
+ 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
+ ),
},
data() {
return {
@@ -47,17 +47,8 @@ export default {
};
},
computed: {
- helpText() {
- return sprintf(
- s__(
- 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
- ),
- {
- linkStart: `<a href="${this.variablesSettingsUrl}">`,
- linkEnd: '</a>',
- },
- false,
- );
+ variableSettings() {
+ return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
},
},
watch: {
@@ -188,8 +179,14 @@ export default {
</div>
</div>
</div>
- <div class="d-flex gl-mt-3 justify-content-center">
- <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p>
+ <div class="gl-text-center gl-mt-3">
+ <gl-sprintf :message="$options.i18n.formHelpText">
+ <template #link="{ content }">
+ <gl-link :href="variableSettings" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
</div>
<div class="d-flex justify-content-center">
<gl-button
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 98badb96ed7..a6eff743ce9 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -46,7 +46,7 @@ export default {
return timeIntervalInWords(this.job.queued);
},
runnerHelpUrl() {
- return helpPagePath('ci/runners/README.html', {
+ return helpPagePath('ci/runners/index.html', {
anchor: 'set-maximum-job-timeout-for-a-runner',
});
},
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 3040d4e2379..97f31eee57c 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -24,3 +24,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
};
export const SUCCESS_STATUS = 'SUCCESS';
+
+export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections';
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 260190f5043..1fb6a6f9850 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -15,7 +15,6 @@ export default () => {
deploymentHelpUrl,
codeQualityHelpUrl,
runnerSettingsUrl,
- variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
pagePath,
@@ -41,7 +40,6 @@ export default () => {
deploymentHelpUrl,
codeQualityHelpUrl,
runnerSettingsUrl,
- variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
pagePath,
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index c89aeada69d..a8be5d8d039 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -99,7 +99,9 @@ export const receiveJobSuccess = ({ commit }, data = {}) => {
};
export const receiveJobError = ({ commit }) => {
commit(types.RECEIVE_JOB_ERROR);
- flash(__('An error occurred while fetching the job.'));
+ createFlash({
+ message: __('An error occurred while fetching the job.'),
+ });
resetFavicon();
};
@@ -197,11 +199,15 @@ export const stopPollingTrace = ({ state, commit }) => {
export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log);
export const receiveTraceError = ({ dispatch }) => {
dispatch('stopPollingTrace');
- flash(__('An error occurred while fetching the job log.'));
+ createFlash({
+ message: __('An error occurred while fetching the job log.'),
+ });
};
export const receiveTraceUnauthorizedError = ({ dispatch }) => {
dispatch('stopPollingTrace');
- flash(__('The current user is not authorized to access the job log.'));
+ createFlash({
+ message: __('The current user is not authorized to access the job log.'),
+ });
};
/**
* When the user clicks a collapsible line in the job
@@ -240,7 +246,9 @@ export const receiveJobsForStageSuccess = ({ commit }, data) =>
commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data);
export const receiveJobsForStageError = ({ commit }) => {
commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR);
- flash(__('An error occurred while fetching the jobs.'));
+ createFlash({
+ message: __('An error occurred while fetching the jobs.'),
+ });
};
export const triggerManualJob = ({ state }, variables) => {
@@ -254,5 +262,9 @@ export const triggerManualJob = ({ state }, variables) => {
.post(state.job.status.action.path, {
job_variables_attributes: parsedVariables,
})
- .catch(() => flash(__('An error occurred while triggering the job.')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while triggering the job.'),
+ }),
+ );
};
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 924b811d0d6..4045d8a0c16 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
+import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
-import { logLinesParser, updateIncrementalTrace } from './utils';
+import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
@@ -20,12 +21,26 @@ export default {
},
[types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
+ const infinitelyCollapsibleSectionsFlag =
+ gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
state.traceState = log.state;
}
if (log.append) {
- state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
+ if (infinitelyCollapsibleSectionsFlag) {
+ if (log.lines) {
+ const parsedResult = logLinesParser(
+ log.lines,
+ state.auxiliaryPartialTraceHelpers,
+ state.trace,
+ );
+ state.trace = parsedResult.parsedLines;
+ state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ }
+ } else {
+ state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
+ }
state.traceSize += log.size;
} else {
@@ -33,7 +48,14 @@ export default {
// the trace response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
- state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
+
+ if (infinitelyCollapsibleSectionsFlag) {
+ const parsedResult = logLinesParser(log.lines);
+ state.trace = parsedResult.parsedLines;
+ state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ } else {
+ state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace;
+ }
state.traceSize = log.size || state.traceSize;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 2fe945b2985..718324c8bad 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -30,4 +30,7 @@ export default () => ({
selectedStage: '',
stages: [],
jobs: [],
+
+ // to parse partial logs
+ auxiliaryPartialTraceHelpers: {},
});
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index a0e0a0fb8bd..36391a4d433 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
-export const logLinesParser = (lines = [], accumulator = []) =>
+export const logLinesParserLegacy = (lines = [], accumulator = []) =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
@@ -131,6 +131,77 @@ export const logLinesParser = (lines = [], accumulator = []) =>
[...accumulator],
);
+export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => {
+ let currentLine = previousTraceState?.prevLineCount ?? 0;
+ let currentHeader = previousTraceState?.currentHeader;
+ let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false;
+ const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
+ const sectionsQueue = previousTraceState?.sectionsQueue ?? [];
+
+ for (let i = 0; i < lines.length; i += 1) {
+ const line = lines[i];
+ // First run we can use the current index, later runs we have to retrieve the last number of lines
+ currentLine = previousTraceState?.prevLineCount ? currentLine + 1 : i + 1;
+
+ if (line.section_header && !isPreviousLineHeader) {
+ // If there's no previous line header that means we're at the root of the log
+
+ isPreviousLineHeader = true;
+ parsedLines.push(parseHeaderLine(line, currentLine));
+ currentHeader = { index: parsedLines.length - 1 };
+ } else if (line.section_header && isPreviousLineHeader) {
+ // If there's a current section, we can't push to the parsedLines array
+ sectionsQueue.push(currentHeader);
+ currentHeader = parseHeaderLine(line, currentLine); // Let's parse the incoming header line
+ } else if (line.section && !line.section_duration) {
+ // We're inside a collapsible section and want to parse a standard line
+ if (currentHeader?.index) {
+ // If the current section header is only an index, add the line as part of the lines
+ // array of the current collapsible section
+ parsedLines[currentHeader.index].lines.push(parseLine(line, currentLine));
+ } else {
+ // Otherwise add it to the innermost collapsible section lines array
+ currentHeader.lines.push(parseLine(line, currentLine));
+ }
+ } else if (line.section && line.section_duration) {
+ // NOTE: This marks the end of a section_header
+ const previousSection = sectionsQueue.pop();
+
+ // Add the duration to section header
+ // If at the root, just push the end to the current parsedLine,
+ // otherwise, push it to the previous sections queue
+ if (currentHeader?.index) {
+ parsedLines[currentHeader.index].line.section_duration = line.section_duration;
+ isPreviousLineHeader = false;
+ currentHeader = null;
+ } else {
+ currentHeader.line.section_duration = line.section_duration;
+
+ if (previousSection && previousSection?.index) {
+ // Is the previous section on root?
+ parsedLines[previousSection.index].lines.push(currentHeader);
+ } else if (previousSection && !previousSection?.index) {
+ previousSection.lines.push(currentHeader);
+ }
+
+ currentHeader = previousSection;
+ }
+ } else {
+ parsedLines.push(parseLine(line, currentLine));
+ }
+ }
+
+ return {
+ parsedLines,
+ auxiliaryPartialTraceHelpers: {
+ isPreviousLineHeader,
+ currentHeader,
+ sectionsQueue,
+ prevLineCount: lines.length,
+ },
+ };
+};
+
/**
* Finds the repeated offset, removes the old one
*
@@ -177,5 +248,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
- return logLinesParser(newLog, parsedLog);
+ return logLinesParserLegacy(newLog, parsedLog);
};
diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js
index 122f23a5bb5..1ccecf3eb53 100644
--- a/app/assets/javascripts/jobs/utils.js
+++ b/app/assets/javascripts/jobs/utils.js
@@ -3,10 +3,10 @@
* https?:\/\/
*
* up until a disallowed character or whitespace
- * [^"<>\\^`{|}\s]+
+ * [^"<>()\\^`{|}\s]+
*
* and a disallowed character or whitespace, including non-ending chars .,:;!?
- * [^"<>\\^`{|}\s.,:;!?]
+ * [^"<>()\\^`{|}\s.,:;!?]
*/
-export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+[^"<>\\^`{|}\s.,:;!?])/g;
+export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g;
export default { linkRegex };
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index 2a020a66fd2..e0068edbb9b 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -3,7 +3,7 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import { dispose } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
@@ -111,7 +111,11 @@ export default class LabelManager {
}
onPrioritySortUpdate() {
- this.savePrioritySort().catch(() => flash(this.errorMessage));
+ this.savePrioritySort().catch(() =>
+ createFlash({
+ message: this.errorMessage,
+ }),
+ );
}
savePrioritySort() {
@@ -123,7 +127,9 @@ export default class LabelManager {
rollbackLabelPosition($label, originalAction) {
const action = originalAction === 'remove' ? 'add' : 'remove';
this.toggleLabelPriority($label, action, false);
- flash(this.errorMessage);
+ createFlash({
+ message: this.errorMessage,
+ });
}
getSortedLabelsIds() {
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index fb88e48c9a6..a62ab301227 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -5,11 +5,11 @@
import $ from 'jquery';
import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions';
import { isScopedLabel } from '~/lib/utils/common_utils';
import boardsStore from './boards/stores/boards_store';
import CreateLabelDropdown from './create_label';
-import { deprecatedCreateFlash as flash } from './flash';
-import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, __ } from './locale';
@@ -148,7 +148,11 @@ export default class LabelsSelect {
container: 'body',
});
})
- .catch(() => flash(__('Error saving label update.')));
+ .catch(() =>
+ createFlash({
+ message: __('Error saving label update.'),
+ }),
+ );
};
initDeprecatedJQueryDropdown($dropdown, {
showMenuAbove,
@@ -183,7 +187,11 @@ export default class LabelsSelect {
$dropdown.data('deprecatedJQueryDropdown').positionMenuAbove();
}
})
- .catch(() => flash(__('Error fetching labels.')));
+ .catch(() =>
+ createFlash({
+ message: __('Error fetching labels.'),
+ }),
+ );
},
renderRow(label) {
let colorEl;
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 76624c81ed5..4357918672d 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -7,6 +7,8 @@ const defaultConfig = {
ADD_TAGS: ['use'],
};
+const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method'];
+
// Only icons urls from `gon` are allowed
const getAllowedIconUrls = (gon = window.gon) =>
[gon.sprite_file_icons, gon.sprite_icons].filter(Boolean);
@@ -44,10 +46,19 @@ const sanitizeSvgIcon = (node) => {
removeUnsafeHref(node, 'xlink:href');
};
+const sanitizeHTMLAttributes = (node) => {
+ forbiddenDataAttrs.forEach((attr) => {
+ if (node.hasAttribute(attr)) {
+ node.removeAttribute(attr);
+ }
+ });
+};
+
addHook('afterSanitizeAttributes', (node) => {
if (node.tagName.toLowerCase() === 'use') {
sanitizeSvgIcon(node);
}
+ sanitizeHTMLAttributes(node);
});
export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config);
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index cec689a44ca..0804213cafa 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -2,12 +2,13 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
-import { createHttpLink } from 'apollo-link-http';
+import { HttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
+import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
export const fetchPolicies = {
@@ -18,6 +19,31 @@ export const fetchPolicies = {
CACHE_ONLY: 'cache-only',
};
+export const stripWhitespaceFromQuery = (url, path) => {
+ /* eslint-disable-next-line no-unused-vars */
+ const [_, params] = url.split(path);
+
+ if (!params) {
+ return url;
+ }
+
+ const decoded = decodeURIComponent(params);
+ const paramsObj = queryToObject(decoded);
+
+ if (!paramsObj.query) {
+ return url;
+ }
+
+ const stripped = paramsObj.query
+ .split(/\s+|\n/)
+ .join(' ')
+ .trim();
+ paramsObj.query = stripped;
+
+ const reassembled = objectToQuery(paramsObj);
+ return `${path}?${reassembled}`;
+};
+
export default (resolvers = {}, config = {}) => {
const {
assumeImmutableResults,
@@ -58,10 +84,31 @@ export default (resolvers = {}, config = {}) => {
});
});
+ /*
+ This custom fetcher intervention is to deal with an issue where we are using GET to access
+ eTag polling, but Apollo Client adds excessive whitespace, which causes the
+ request to fail on certain self-hosted stacks. When we can move
+ to subscriptions entirely or can land an upstream PR, this can be removed.
+
+ Related links
+ Bug report: https://gitlab.com/gitlab-org/gitlab/-/issues/329895
+ Moving to subscriptions: https://gitlab.com/gitlab-org/gitlab/-/issues/332485
+ Apollo Client issue: https://github.com/apollographql/apollo-feature-requests/issues/182
+ */
+
+ const fetchIntervention = (url, options) => {
+ return fetch(stripWhitespaceFromQuery(url, uri), options);
+ };
+
+ const requestLink = ApolloLink.split(
+ () => useGet,
+ new HttpLink({ ...httpOptions, fetch: fetchIntervention }),
+ new BatchHttpLink(httpOptions),
+ );
+
const uploadsLink = ApolloLink.split(
(operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
- useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
);
const performanceBarLink = new ApolloLink((operation, forward) => {
@@ -99,6 +146,7 @@ export default (resolvers = {}, config = {}) => {
new StartupJSLink(),
apolloCaptchaLink,
uploadsLink,
+ requestLink,
]),
);
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 204c84b879e..0a26f78e253 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -1,4 +1,5 @@
import axios from 'axios';
+import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import setupAxiosStartupCalls from './axios_startup_calls';
import csrf from './csrf';
import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
@@ -41,6 +42,8 @@ axios.interceptors.response.use(
(err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating),
);
+registerCaptchaModalInterceptor(axios);
+
export default axios;
/**
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8666d325c1b..8a051041fbe 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -11,31 +11,10 @@ import { isObject } from './type_utility';
import { getLocationHash } from './url_utility';
export const getPagePath = (index = 0) => {
- const page = $('body').attr('data-page') || '';
-
+ const { page = '' } = document?.body?.dataset;
return page.split(':')[index];
};
-export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null;
-
-export const isInGroupsPage = () => getPagePath() === 'groups';
-
-export const isInProjectPage = () => getPagePath() === 'projects';
-
-export const getProjectSlug = () => {
- if (isInProjectPage()) {
- return $('body').data('project');
- }
- return null;
-};
-
-export const getGroupSlug = () => {
- if (isInProjectPage() || isInGroupsPage()) {
- return $('body').data('group');
- }
- return null;
-};
-
export const checkPageAndAction = (page, action) => {
const pagePath = getPagePath(1);
const actionPath = getPagePath(2);
@@ -49,6 +28,8 @@ export const isInDesignPage = () => checkPageAndAction('issues', 'designs');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
+export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null;
+
export const getCspNonceValue = () => {
const metaTag = document.querySelector('meta[name=csp-nonce]');
return metaTag && metaTag.content;
@@ -162,53 +143,6 @@ export const parseUrlPathname = (url) => {
return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`;
};
-const splitPath = (path = '') => path.replace(/^\?/, '').split('&');
-
-export const urlParamsToArray = (path = '') =>
- splitPath(path)
- .filter((param) => param.length > 0)
- .map((param) => {
- const split = param.split('=');
- return [decodeURI(split[0]), split[1]].join('=');
- });
-
-export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
-
-/**
- * Accepts encoding string which includes query params being
- * sent to URL.
- *
- * @param {string} path Query param string
- *
- * @returns {object} Query params object containing key-value pairs
- * with both key and values decoded into plain string.
- */
-export const urlParamsToObject = (path = '') =>
- splitPath(path).reduce((dataParam, filterParam) => {
- if (filterParam === '') {
- return dataParam;
- }
-
- const data = dataParam;
- let [key, value] = filterParam.split('=');
- key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
- const isArray = key.includes('[]');
- key = key.replace('[]', '');
- value = decodeURIComponent(value.replace(/\+/g, ' '));
-
- if (isArray) {
- if (!data[key]) {
- data[key] = [];
- }
-
- data[key].push(value);
- } else {
- data[key] = value;
- }
-
- return data;
- }, {});
-
export const isMetaKey = (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
// Identify following special clicks
@@ -301,21 +235,6 @@ export const debounceByAnimationFrame = (fn) => {
};
};
-/**
- this will take in the `name` of the param you want to parse in the url
- if the name does not exist this function will return `null`
- otherwise it will return the value of the param key provided
-*/
-export const getParameterByName = (name, urlToParse) => {
- const url = urlToParse || window.location.href;
- const parsedName = name.replace(/[[\]]/g, '\\$&');
- const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`);
- const results = regex.exec(url);
- if (!results) return null;
- if (!results[2]) return '';
- return decodeURIComponent(results[2].replace(/\+/g, ' '));
-};
-
const handleSelectedRange = (range, restrictToNode) => {
// Make sure this range is within the restricting container
if (restrictToNode && !range.intersectsNode(restrictToNode)) return null;
@@ -390,8 +309,8 @@ export const insertText = (target, text) => {
};
/**
- this will take in the headers from an API response and normalize them
- this way we don't run into production issues when nginx gives us lowercased header keys
+ this will take in the headers from an API response and normalize them
+ this way we don't run into production issues when nginx gives us lowercased header keys
*/
export const normalizeHeaders = (headers) => {
const upperCaseHeaders = {};
@@ -418,39 +337,6 @@ export const parseIntPagination = (paginationInformation) => ({
previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10),
});
-/**
- * Given a string of query parameters creates an object.
- *
- * @example
- * `scope=all&page=2` -> { scope: 'all', page: '2'}
- * `scope=all` -> { scope: 'all' }
- * ``-> {}
- * @param {String} query
- * @returns {Object}
- */
-export const parseQueryStringIntoObject = (query = '') => {
- if (query === '') return {};
-
- return query.split('&').reduce((acc, element) => {
- const val = element.split('=');
- Object.assign(acc, {
- [val[0]]: decodeURIComponent(val[1]),
- });
- return acc;
- }, {});
-};
-
-/**
- * Converts object with key-value pairs
- * into query-param string
- *
- * @param {Object} params
- */
-export const objectToQueryString = (params = {}) =>
- Object.keys(params)
- .map((param) => `${param}=${params[param]}`)
- .join('&');
-
export const buildUrlWithCurrentLocation = (param) => {
if (param) return `${window.location.pathname}${param}`;
@@ -789,7 +675,18 @@ export const searchBy = (query = '', searchSpace = {}) => {
* @param {Object} label
* @returns Boolean
*/
-export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1;
+export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1;
+
+/**
+ * Returns the base value of the scoped label
+ *
+ * Expected Label to be an Object with `title` as a key:
+ * { title: 'LabelTitle', ...otherProperties };
+ *
+ * @param {Object} label
+ * @returns String
+ */
+export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0];
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
@@ -821,3 +718,5 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag];
* @returns {Array[String]} Converted array
*/
export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i));
+
+export const isLoggedIn = () => Boolean(window.gon?.current_user_id);
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 2d4765f54b9..e41de72ded4 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,4 +1,5 @@
export const BYTES_IN_KIB = 1024;
+export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
index 512b1f079a1..d68682ebed1 100644
--- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
@@ -1,10 +1,7 @@
-import $ from 'jquery';
import * as timeago from 'timeago.js';
-import { languageCode, s__ } from '../../../locale';
+import { languageCode, s__, createDateTimeFormat } from '../../../locale';
import { formatDate } from './date_format_utility';
-window.timeago = timeago;
-
/**
* Timeago uses underscores instead of dashes to separate language from country code.
*
@@ -76,24 +73,44 @@ const memoizedLocale = () => {
timeago.register(timeagoLanguageCode, memoizedLocale());
timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
-export const getTimeago = () => timeago;
+let memoizedFormatter = null;
+
+function setupAbsoluteFormatter() {
+ if (memoizedFormatter === null) {
+ const formatter = createDateTimeFormat({
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ });
+
+ memoizedFormatter = {
+ format(date) {
+ return formatter.format(date instanceof Date ? date : new Date(date));
+ },
+ };
+ }
+ return memoizedFormatter;
+}
+
+export const getTimeago = () =>
+ window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago;
/**
* For the given elements, sets a tooltip with a formatted date.
- * @param {JQuery} $timeagoEls
- * @param {Boolean} setTimeago
+ * @param {Array<Node>|NodeList} elements
+ * @param {Boolean} updateTooltip
*/
-export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
- $timeagoEls.each((i, el) => {
- $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode));
+export const localTimeAgo = (elements, updateTooltip = true) => {
+ const { format } = getTimeago();
+ elements.forEach((el) => {
+ el.innerText = format(el.dateTime, timeagoLanguageCode);
});
- if (!setTimeago) {
+ if (!updateTooltip) {
return;
}
function addTimeAgoTooltip() {
- $timeagoEls.each((i, el) => {
+ elements.forEach((el) => {
// Recreate with custom template
el.setAttribute('title', formatDate(el.dateTime));
});
@@ -116,9 +133,3 @@ export const timeFor = (time, expiredLabel) => {
}
return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
};
-
-window.gl = window.gl || {};
-window.gl.utils = {
- ...(window.gl.utils || {}),
- localTimeAgo,
-};
diff --git a/app/assets/javascripts/lib/utils/finite_state_machine.js b/app/assets/javascripts/lib/utils/finite_state_machine.js
new file mode 100644
index 00000000000..99eeb7cb947
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/finite_state_machine.js
@@ -0,0 +1,101 @@
+/**
+ * @module finite_state_machine
+ */
+
+/**
+ * The states to be used with state machine definitions
+ * @typedef {Object} FiniteStateMachineStates
+ * @property {!Object} ANY_KEY - Any key that maps to a known state
+ * @property {!Object} ANY_KEY.on - A dictionary of transition events for the ANY_KEY state that map to a different state
+ * @property {!String} ANY_KEY.on.ANY_EVENT - The resulting state that the machine should end at
+ */
+
+/**
+ * An object whose minimum definition defined here can be used to guard UI state transitions
+ * @typedef {Object} StatelessFiniteStateMachineDefinition
+ * @property {FiniteStateMachineStates} states
+ */
+
+/**
+ * An object whose minimum definition defined here can be used to create a live finite state machine
+ * @typedef {Object} LiveFiniteStateMachineDefinition
+ * @property {String} initial - The initial state for this machine
+ * @property {FiniteStateMachineStates} states
+ */
+
+/**
+ * An object that allows interacting with a stateful, live finite state machine
+ * @typedef {Object} LiveStateMachine
+ * @property {String} value - The current state of this machine
+ * @property {Object} states - The states from when the machine definition was constructed
+ * @property {Function} is - {@link module:finite_state_machine~is LiveStateMachine.is}
+ * @property {Function} send - {@link module:finite_state_machine~send LiveStatemachine.send}
+ */
+
+// This is not user-facing functionality
+/* eslint-disable @gitlab/require-i18n-strings */
+
+function hasKeys(object, keys) {
+ return keys.every((key) => Object.keys(object).includes(key));
+}
+
+/**
+ * Get an updated state given a machine definition, a starting state, and a transition event
+ * @param {StatelessFiniteStateMachineDefinition} definition
+ * @param {String} current - The current known state
+ * @param {String} event - A transition event
+ * @returns {String} A state value
+ */
+export function transition(definition, current, event) {
+ return definition?.states?.[current]?.on[event] || current;
+}
+
+function startMachine({ states, initial } = {}) {
+ let current = initial;
+
+ return {
+ /**
+ * A convenience function to test arbitrary input against the machine's current state
+ * @param {String} testState - The value to test against the machine's current state
+ */
+ is(testState) {
+ return current === testState;
+ },
+ /**
+ * A function to transition the live state machine using an arbitrary event
+ * @param {String} event - The event to send to the machine
+ * @returns {String} A string representing the current state. Note this may not have changed if the current state + transition event combination are not valid.
+ */
+ send(event) {
+ current = transition({ states }, current, event);
+
+ return current;
+ },
+ get value() {
+ return current;
+ },
+ set value(forcedState) {
+ current = forcedState;
+ },
+ states,
+ };
+}
+
+/**
+ * Create a live state machine
+ * @param {LiveFiniteStateMachineDefinition} definition
+ * @returns {LiveStateMachine} A live state machine
+ */
+export function machine(definition) {
+ if (!hasKeys(definition, ['initial', 'states'])) {
+ throw new Error(
+ 'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)',
+ );
+ } else if (!hasKeys(definition.states, [definition.initial])) {
+ throw new Error(
+ `Cannot initialize the state machine to state '${definition.initial}'. Is that one of the machine's defined states?`,
+ );
+ } else {
+ return startMachine(definition);
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index eaf396a7a59..5ee00464a8b 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -421,3 +421,61 @@ export const isValidSha1Hash = (str) => {
export function insertFinalNewline(content, endOfLine = '\n') {
return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content;
}
+
+export const markdownConfig = {
+ // allowedTags from GitLab's inline HTML guidelines
+ // https://docs.gitlab.com/ee/user/markdown.html#inline-html
+ ALLOWED_TAGS: [
+ 'a',
+ 'abbr',
+ 'b',
+ 'blockquote',
+ 'br',
+ 'code',
+ 'dd',
+ 'del',
+ 'div',
+ 'dl',
+ 'dt',
+ 'em',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'hr',
+ 'i',
+ 'img',
+ 'ins',
+ 'kbd',
+ 'li',
+ 'ol',
+ 'p',
+ 'pre',
+ 'q',
+ 'rp',
+ 'rt',
+ 'ruby',
+ 's',
+ 'samp',
+ 'span',
+ 'strike',
+ 'strong',
+ 'sub',
+ 'summary',
+ 'sup',
+ 'table',
+ 'tbody',
+ 'td',
+ 'tfoot',
+ 'th',
+ 'thead',
+ 'tr',
+ 'tt',
+ 'ul',
+ 'var',
+ ],
+ ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
+ ALLOW_DATA_ATTR: false,
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index d68b41b7f7a..7922ff22a70 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -209,11 +209,7 @@ export function removeParams(params, url = window.location.href, skipEncoding =
return `${root}${writableQuery}${writableFragment}`;
}
-export function getLocationHash(url = window.location.href) {
- const hashIndex = url.indexOf('#');
-
- return hashIndex === -1 ? null : url.substring(hashIndex + 1);
-}
+export const getLocationHash = (hash = window.location.hash) => hash.split('#')[1];
/**
* Returns a boolean indicating whether the URL hash contains the given string value
@@ -409,6 +405,55 @@ export function getWebSocketUrl(path) {
return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`;
}
+const splitPath = (path = '') => path.replace(/^\?/, '').split('&');
+
+export const urlParamsToArray = (path = '') =>
+ splitPath(path)
+ .filter((param) => param.length > 0)
+ .map((param) => {
+ const split = param.split('=');
+ return [decodeURI(split[0]), split[1]].join('=');
+ });
+
+export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
+
+/**
+ * Accepts encoding string which includes query params being
+ * sent to URL.
+ *
+ * @param {string} path Query param string
+ *
+ * @returns {object} Query params object containing key-value pairs
+ * with both key and values decoded into plain string.
+ *
+ * @deprecated Please use `queryToObject(query, { gatherArrays: true });` instead. See https://gitlab.com/gitlab-org/gitlab/-/issues/328845
+ */
+export const urlParamsToObject = (path = '') =>
+ splitPath(path).reduce((dataParam, filterParam) => {
+ if (filterParam === '') {
+ return dataParam;
+ }
+
+ const data = dataParam;
+ let [key, value] = filterParam.split('=');
+ key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
+ const isArray = key.includes('[]');
+ key = key.replace('[]', '');
+ value = decodeURIComponent(value.replace(/\+/g, ' '));
+
+ if (isArray) {
+ if (!data[key]) {
+ data[key] = [];
+ }
+
+ data[key].push(value);
+ } else {
+ data[key] = value;
+ }
+
+ return data;
+ }, {});
+
/**
* Convert search query into an object
*
@@ -450,17 +495,30 @@ export function queryToObject(query, { gatherArrays = false, legacySpacesDecode
}
/**
+ * This function accepts the `name` of the param to parse in the url
+ * if the name does not exist this function will return `null`
+ * otherwise it will return the value of the param key provided
+ *
+ * @param {String} name
+ * @param {String?} urlToParse
+ * @returns value of the parameter as string
+ */
+export const getParameterByName = (name, query = window.location.search) => {
+ return queryToObject(query)[name] || null;
+};
+
+/**
* Convert search query object back into a search query
*
- * @param {Object} obj that needs to be converted
+ * @param {Object?} params that needs to be converted
* @returns {String}
*
* ex: {one: 1, two: 2} into "one=1&two=2"
*
*/
-export function objectToQuery(obj) {
- return Object.keys(obj)
- .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`)
+export function objectToQuery(params = {}) {
+ return Object.keys(params)
+ .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
}
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index aaa8ee40966..a1f59aa1b54 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -117,8 +117,8 @@ LineHighlighter.prototype.clearHighlight = function () {
//
// Returns an Array
LineHighlighter.prototype.hashToRange = function (hash) {
- // ?L(\d+)(?:-(\d+))?$/)
- const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
+ // ?L(\d+)(?:-L?(\d+))?$/)
+ const matches = hash.match(/^#?L(\d+)(?:-L?(\d+))?$/);
if (matches && matches.length) {
const first = parseInt(matches[1], 10);
const last = matches[2] ? parseInt(matches[2], 10) : null;
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 10518fa73d9..ad01da2eb17 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -2,7 +2,10 @@ import Jed from 'jed';
import ensureSingleLine from './ensure_single_line';
import sprintf from './sprintf';
-const languageCode = () => document.querySelector('html').getAttribute('lang') || 'en';
+const GITLAB_FALLBACK_LANGUAGE = 'en';
+
+const languageCode = () =>
+ document.querySelector('html').getAttribute('lang') || GITLAB_FALLBACK_LANGUAGE;
const locale = new Jed(window.translations || {});
delete window.translations;
@@ -51,12 +54,52 @@ const pgettext = (keyOrContext, key) => {
};
/**
+ * Filters navigator languages by the set GitLab language.
+ *
+ * This allows us to decide better what a user wants as a locale, for using with the Intl browser APIs.
+ * If they have set their GitLab to a language, it will check whether `navigator.languages` contains matching ones.
+ * This function always adds `en` as a fallback in order to have date renders if all fails before it.
+ *
+ * - Example one: GitLab language is `en` and browser languages are:
+ * `['en-GB', 'en-US']`. This function returns `['en-GB', 'en-US', 'en']` as
+ * the preferred locales, the Intl APIs would try to format first as British English,
+ * if that isn't available US or any English.
+ * - Example two: GitLab language is `en` and browser languages are:
+ * `['de-DE', 'de']`. This function returns `['en']`, so the Intl APIs would prefer English
+ * formatting in order to not have German dates mixed with English GitLab UI texts.
+ * If the user wants for example British English formatting (24h, etc),
+ * they could set their browser languages to `['de-DE', 'de', 'en-GB']`.
+ * - Example three: GitLab language is `de` and browser languages are `['en-US', 'en']`.
+ * This function returns `['de', 'en']`, aligning German dates with the chosen translation of GitLab.
+ *
+ * @returns {string[]}
+ */
+export const getPreferredLocales = () => {
+ const gitlabLanguage = languageCode();
+ // The GitLab language may or may not contain a country code,
+ // so we create the short version as well, e.g. de-AT => de
+ const lang = gitlabLanguage.substring(0, 2);
+ const locales = navigator.languages.filter((l) => l.startsWith(lang));
+ if (!locales.includes(gitlabLanguage)) {
+ locales.push(gitlabLanguage);
+ }
+ if (!locales.includes(lang)) {
+ locales.push(lang);
+ }
+ if (!locales.includes(GITLAB_FALLBACK_LANGUAGE)) {
+ locales.push(GITLAB_FALLBACK_LANGUAGE);
+ }
+ return locales;
+};
+
+/**
Creates an instance of Intl.DateTimeFormat for the current locale.
@param formatOptions for available options, please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
@returns {Intl.DateTimeFormat}
*/
-const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions);
+const createDateTimeFormat = (formatOptions) =>
+ Intl.DateTimeFormat(getPreferredLocales(), formatOptions);
/**
* Formats a number as a string using `toLocaleString`.
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 39041aa1447..3db9fa01629 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -29,9 +29,6 @@ export default {
LogAdvancedFilters,
LogControlButtons,
},
- filters: {
- formatDate,
- },
props: {
environmentName: {
type: String,
@@ -114,6 +111,7 @@ export default {
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
}, 200),
+ formatDate,
},
};
</script>
@@ -229,8 +227,8 @@ export default {
<div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900">
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
- <template #start>{{ timeRange.current.start | formatDate }}</template>
- <template #end>{{ timeRange.current.end | formatDate }}</template>
+ <template #start>{{ formatDate(timeRange.current.start) }}</template>
+ <template #end>{{ formatDate(timeRange.current.end) }}</template>
</gl-sprintf>
<gl-sprintf
v-if="!logs.isComplete"
diff --git a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
index f8ce704942b..4e672c1d121 100644
--- a/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
+++ b/app/assets/javascripts/logs/components/tokens/token_with_loading_state.vue
@@ -20,7 +20,7 @@ export default {
<gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners">
<template #suggestions>
<div class="m-1">
- <gl-loading-icon v-if="config.loading" />
+ <gl-loading-icon v-if="config.loading" size="sm" />
<div v-else class="py-1 px-2 text-muted">
{{ config.noOptionsText }}
</div>
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 2309f7a420f..5c14000a2aa 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -31,7 +31,7 @@ import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initPersistentUserCallouts from './persistent_user_callouts';
import { initUserTracking, initDefaultTrackers } from './tracking';
-import initUsagePingConsent from './usage_ping_consent';
+import initServicePingConsent from './service_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
@@ -46,6 +46,9 @@ applyGitLabUIConfig();
window.jQuery = jQuery;
window.$ = jQuery;
+// ensure that window.gl is set up
+window.gl = window.gl || {};
+
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
import(/* webpackMode: "eager" */ './test_utils/');
@@ -86,7 +89,7 @@ function deferredInitialisation() {
initBreadcrumbs();
initTodoToggle();
initLogoAnimation();
- initUsagePingConsent();
+ initServicePingConsent();
initUserPopovers();
initBroadcastNotifications();
initFrequentItemDropdowns();
@@ -183,7 +186,7 @@ document.addEventListener('DOMContentLoaded', () => {
return true;
});
- localTimeAgo($('abbr.timeago, .js-timeago'), true);
+ localTimeAgo(document.querySelectorAll('abbr.timeago, .js-timeago'), true);
/**
* This disables form buttons while a form is submitting
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 1e9f79927ea..0c20f935d50 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -38,6 +38,7 @@ export default {
usersName: user.name,
source: source.fullName,
},
+ false,
);
}
diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue
index a08518584f3..0ec39f58930 100644
--- a/app/assets/javascripts/members/components/app.vue
+++ b/app/assets/javascripts/members/components/app.vue
@@ -19,6 +19,11 @@ export default {
type: String,
required: true,
},
+ tabQueryParamValue: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapState({
@@ -55,6 +60,6 @@ export default {
errorMessage
}}</gl-alert>
<filter-sort-container />
- <members-table />
+ <members-table :tab-query-param-value="tabQueryParamValue" />
</div>
</template>
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index cc0533391df..33d86dec767 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,10 +1,13 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { getParameterByName, urlParamsToObject } from '~/lib/utils/common_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { getParameterByName, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants';
+import {
+ SEARCH_TOKEN_TYPE,
+ SORT_QUERY_PARAM_NAME,
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+} from '~/members/constants';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@@ -64,7 +67,7 @@ export default {
},
},
created() {
- const query = urlParamsToObject(window.location.search);
+ const query = queryToObject(window.location.search);
const tokens = this.tokens
.filter((token) => query[token.type])
@@ -116,10 +119,15 @@ export default {
return accumulator;
}, {});
- const sortParam = getParameterByName(SORT_PARAM);
+ const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME);
+ const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME);
window.location.href = setUrlParams(
- { ...params, ...(sortParam && { sort: sortParam }) },
+ {
+ ...params,
+ ...(sortParamValue && { [SORT_QUERY_PARAM_NAME]: sortParamValue }),
+ ...(activeTabParamValue && { [ACTIVE_TAB_QUERY_PARAM_NAME]: activeTabParamValue }),
+ },
window.location.href,
true,
);
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index 37b9135126d..7c21e33d892 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -1,16 +1,18 @@
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
+// eslint-disable-next-line import/no-deprecated
+import { urlParamsToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import { MEMBER_TYPES } from '../constants';
+import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants';
import MembersApp from './app.vue';
const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0;
export default {
name: 'MembersTabs',
- tabs: [
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+ TABS: [
{
namespace: MEMBER_TYPES.user,
title: __('Members'),
@@ -19,19 +21,21 @@ export default {
namespace: MEMBER_TYPES.group,
title: __('Groups'),
attrs: { 'data-qa-selector': 'groups_list_tab' },
+ queryParamValue: TAB_QUERY_PARAM_VALUES.group,
},
{
namespace: MEMBER_TYPES.invite,
title: __('Invited'),
canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.invite,
},
{
namespace: MEMBER_TYPES.accessRequest,
title: __('Access requests'),
canManageMembersPermissionsRequired: true,
+ queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
},
],
- urlParams: [],
components: { MembersApp, GlTabs, GlTab, GlBadge },
inject: ['canManageMembers'],
data() {
@@ -55,32 +59,22 @@ export default {
},
}),
urlParams() {
+ // eslint-disable-next-line import/no-deprecated
return Object.keys(urlParamsToObject(window.location.search));
},
activeTabIndexCalculatedFromUrlParams() {
- return this.$options.tabs.findIndex(({ namespace }) => {
+ return this.$options.TABS.findIndex(({ namespace }) => {
return this.getTabUrlParams(namespace).some((urlParam) =>
this.urlParams.includes(urlParam),
);
});
},
},
- created() {
- if (this.activeTabIndexCalculatedFromUrlParams === -1) {
- return;
- }
-
- this.selectedTabIndex = this.activeTabIndexCalculatedFromUrlParams;
- },
methods: {
getTabUrlParams(namespace) {
const state = this.$store.state[namespace];
const urlParams = [];
- if (state?.pagination?.paramName) {
- urlParams.push(state.pagination.paramName);
- }
-
if (state?.filteredSearchBar?.searchParam) {
urlParams.push(state.filteredSearchBar.searchParam);
}
@@ -110,14 +104,23 @@ export default {
</script>
<template>
- <gl-tabs v-model="selectedTabIndex">
- <template v-for="(tab, index) in $options.tabs">
- <gl-tab v-if="showTab(tab, index)" :key="tab.namespace" :title-link-attributes="tab.attrs">
- <template slot="title">
+ <gl-tabs
+ v-model="selectedTabIndex"
+ sync-active-tab-with-query-params
+ :query-param-name="$options.ACTIVE_TAB_QUERY_PARAM_NAME"
+ >
+ <template v-for="(tab, index) in $options.TABS">
+ <gl-tab
+ v-if="showTab(tab, index)"
+ :key="tab.namespace"
+ :title-link-attributes="tab.attrs"
+ :query-param-value="tab.queryParamValue"
+ >
+ <template #title>
<span>{{ tab.title }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">{{ getTabCount(tab) }}</gl-badge>
</template>
- <members-app :namespace="tab.namespace" />
+ <members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" />
</gl-tab>
</template>
</gl-tabs>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 09ef98ec411..b9c80edbc49 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -5,7 +5,7 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
-import { FIELDS } from '../../constants';
+import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
@@ -34,6 +34,13 @@ export default {
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
inject: ['namespace', 'currentUserId'],
+ props: {
+ tabQueryParamValue: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
computed: {
...mapState({
members(state) {
@@ -112,7 +119,15 @@ export default {
paginationLinkGenerator(page) {
const { params = {}, paramName } = this.pagination;
- return mergeUrlParams({ ...params, [paramName]: page }, window.location.href);
+ return mergeUrlParams(
+ {
+ ...params,
+ [ACTIVE_TAB_QUERY_PARAM_NAME]:
+ this.tabQueryParamValue !== '' ? this.tabQueryParamValue : null,
+ [paramName]: page,
+ },
+ window.location.href,
+ );
},
},
};
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index f68a8814fee..6f465245d20 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -89,6 +89,12 @@ export const MEMBER_TYPES = {
accessRequest: 'accessRequest',
};
+export const TAB_QUERY_PARAM_VALUES = {
+ group: 'groups',
+ invite: 'invited',
+ accessRequest: 'access_requests',
+};
+
export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
@@ -97,7 +103,8 @@ export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
export const SEARCH_TOKEN_TYPE = 'filtered-search-term';
-export const SORT_PARAM = 'sort';
+export const SORT_QUERY_PARAM_NAME = 'sort';
+export const ACTIVE_TAB_QUERY_PARAM_NAME = 'tab';
export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level';
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index be549b40885..05f086c8f4f 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -1,6 +1,6 @@
import { isUndefined } from 'lodash';
-import { getParameterByName, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
FIELDS,
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index 04e493712ec..7168efa28ad 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions } from 'vuex';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE } from '../constants';
@@ -50,13 +50,13 @@ export default {
methods: {
...mapActions(['setFileResolveMode', 'setPromptConfirmationState', 'updateFile']),
loadEditor() {
- const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
+ const EditorPromise = import(/* webpackChunkName: 'SourceEditor' */ '~/editor/source_editor');
const DataPromise = axios.get(this.file.content_path);
Promise.all([EditorPromise, DataPromise])
.then(
([
- { default: EditorLite },
+ { default: SourceEditor },
{
data: { content, new_path: path },
},
@@ -66,7 +66,7 @@ export default {
this.originalContent = content;
this.fileLoaded = true;
- this.editor = new EditorLite().createInstance({
+ this.editor = new SourceEditor().createInstance({
el: contentEl,
blobPath: path,
blobContent: content,
@@ -75,7 +75,9 @@ export default {
},
)
.catch(() => {
- flash(__('An error occurred while loading the file'));
+ createFlash({
+ message: __('An error occurred while loading the file'),
+ });
});
},
saveDiffResolution() {
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 3e31e2e93ae..5fcc778a714 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -120,7 +120,7 @@ export default {
>
<div class="js-file-title file-title file-title-flex-parent cursor-default">
<div class="file-header-content" data-testid="file-name">
- <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" />
+ <file-icon :file-name="file.filePath" :size="16" css-classes="gl-mr-2" />
<strong class="file-title-name">{{ file.filePath }}</strong>
</div>
<div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start">
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index feaf8b0d996..0ddb2c2334c 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -148,14 +148,6 @@ MergeRequest.prototype.initCommitMessageListeners = function () {
});
};
-MergeRequest.setStatusBoxToMerged = function () {
- $('.detail-page-header .status-box')
- .removeClass('status-box-open')
- .addClass('status-box-mr-merged')
- .find('span')
- .text(__('Merged'));
-};
-
MergeRequest.decreaseCounter = function (by = 1) {
const $el = $('.js-merge-counter');
const count = Math.max(parseInt($el.text().replace(/[^\d]/, ''), 10) - by, 0);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index d5db9f43d09..1d1c0a23fab 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -3,12 +3,10 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import Cookies from 'js-cookie';
import Vue from 'vue';
-import CommitPipelinesTable from '~/commit/pipelines/pipelines_table.vue';
import createEventHub from '~/helpers/event_hub_factory';
-import initAddContextCommitsTriggers from './add_context_commits_modal';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import initChangesDropdown from './init_changes_dropdown';
import axios from './lib/utils/axios_utils';
import {
@@ -335,17 +333,22 @@ export default class MergeRequestTabs {
axios
.get(`${source}.json`)
.then(({ data }) => {
- document.querySelector('div#commits').innerHTML = data.html;
- localTimeAgo($('.js-timeago', 'div#commits'));
+ const commitsDiv = document.querySelector('div#commits');
+ commitsDiv.innerHTML = data.html;
+ localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
this.scrollToContainerElement('#commits');
this.toggleLoading(false);
- initAddContextCommitsTriggers();
+
+ return import('./add_context_commits_modal');
})
+ .then((m) => m.default())
.catch(() => {
this.toggleLoading(false);
- flash(__('An error occurred while fetching this tab.'));
+ createFlash({
+ message: __('An error occurred while fetching this tab.'),
+ });
});
}
@@ -354,13 +357,16 @@ export default class MergeRequestTabs {
const { mrWidgetData } = gl;
this.commitPipelinesTable = new Vue({
+ components: {
+ CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
+ },
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
},
render(createElement) {
- return createElement(CommitPipelinesTable, {
+ return createElement('commit-pipelines-table', {
props: {
endpoint: pipelineTableViewEl.dataset.endpoint,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
@@ -402,7 +408,7 @@ export default class MergeRequestTabs {
initChangesDropdown(this.stickyTop);
- localTimeAgo($('.js-timeago', 'div#diffs'));
+ localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
if (this.isDiffAction(this.currentAction)) {
@@ -446,7 +452,9 @@ export default class MergeRequestTabs {
})
.catch(() => {
this.toggleLoading(false);
- flash(__('An error occurred while fetching this tab.'));
+ createFlash({
+ message: __('An error occurred while fetching this tab.'),
+ });
});
}
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index 280613bda49..b4e53c1fab6 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
@@ -39,7 +39,11 @@ export default class Milestone {
$(tabElId).html(data.html);
$target.addClass('is-loaded');
})
- .catch(() => flash(__('Error loading milestone tab')));
+ .catch(() =>
+ createFlash({
+ message: __('Error loading milestone tab'),
+ }),
+ );
}
}
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index b992eaff779..0d9a2eef01a 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -7,6 +7,7 @@ import { template, escape } from 'lodash';
import Api from '~/api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __, sprintf } from '~/locale';
+import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import boardsStore, {
boardStoreIssueSet,
boardStoreIssueDelete,
@@ -93,21 +94,7 @@ export default class MilestoneSelect {
// Public API includes `title` instead of `name`.
name: m.title,
}))
- .sort((mA, mB) => {
- const dueDateA = mA.due_date ? parsePikadayDate(mA.due_date) : null;
- const dueDateB = mB.due_date ? parsePikadayDate(mB.due_date) : null;
-
- // Move all expired milestones to the bottom.
- if (mA.expired) return 1;
- if (mB.expired) return -1;
-
- // Move milestones without due dates just above expired milestones.
- if (!dueDateA) return 1;
- if (!dueDateB) return -1;
-
- // Sort by due date in ascending order.
- return dueDateA - dueDateB;
- }),
+ .sort(sortMilestonesByDueDate),
)
.then((data) => {
const extraOptions = [];
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index 1db2d10db20..e8499015210 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -171,7 +171,7 @@ export default {
<template>
<gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox">
- <template slot="button-content">
+ <template #button-content>
<span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{
selectedMilestonesLabel
}}</span>
@@ -202,7 +202,7 @@ export default {
<gl-dropdown-divider />
<template v-if="isLoading">
- <gl-loading-icon />
+ <gl-loading-icon size="sm" />
<gl-dropdown-divider />
</template>
<template v-else-if="showNoResults">
diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/milestone_utils.js
new file mode 100644
index 00000000000..3ae5e676138
--- /dev/null
+++ b/app/assets/javascripts/milestones/milestone_utils.js
@@ -0,0 +1,32 @@
+import { parsePikadayDate } from '~/lib/utils/datetime_utility';
+
+/**
+ * This method is to be used with `Array.prototype.sort` function
+ * where array contains milestones with `due_date`/`dueDate` and/or
+ * `expired` properties.
+ * This method sorts given milestone params based on their expiration
+ * status by putting expired milestones at the bottom and upcoming
+ * milestones at the top of the list.
+ *
+ * @param {object} milestoneA
+ * @param {object} milestoneB
+ */
+export function sortMilestonesByDueDate(milestoneA, milestoneB) {
+ const rawDueDateA = milestoneA.due_date || milestoneA.dueDate;
+ const rawDueDateB = milestoneB.due_date || milestoneB.dueDate;
+ const dueDateA = rawDueDateA ? parsePikadayDate(rawDueDateA) : null;
+ const dueDateB = rawDueDateB ? parsePikadayDate(rawDueDateB) : null;
+ const expiredA = milestoneA.expired || Date.now() > dueDateA?.getTime();
+ const expiredB = milestoneB.expired || Date.now() > dueDateB?.getTime();
+
+ // Move all expired milestones to the bottom.
+ if (expiredA) return 1;
+ if (expiredB) return -1;
+
+ // Move milestones without due dates just above expired milestones.
+ if (!dueDateA) return 1;
+ if (!dueDateB) return -1;
+
+ // Sort by due date in ascending order.
+ return dueDateA - dueDateB;
+}
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index a26c8f85958..e59da18fb77 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { debounce } from 'lodash';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { hide } from '~/tooltips';
@@ -111,7 +111,11 @@ export default class MirrorRepos {
return axios
.put(this.mirrorEndpoint, payload)
.then(() => this.removeRow($target))
- .catch(() => Flash(__('Failed to remove mirror.')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to remove mirror.'),
+ }),
+ );
}
/* eslint-disable class-methods-use-this */
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 15ded478405..5138c450feb 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { escape } from 'lodash';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -115,7 +115,9 @@ export default class SSHMirror {
const failureMessage = response.data
? response.data.message
: __('An error occurred while detecting host keys');
- Flash(failureMessage);
+ createFlash({
+ message: failureMessage,
+ });
$btnLoadSpinner.addClass('hidden');
this.$btnDetectHostKeys.enable();
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
index c18c13f2574..e5d7e2ea2eb 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget.vue
@@ -227,7 +227,7 @@ export default {
<template>
<div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
- <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" />
+ <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" size="sm" />
<span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
errorMessage
}}</span>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 99008d047af..12f5e7efc96 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -402,22 +402,20 @@ export default {
@created="onChartCreated"
@updated="onChartUpdated"
>
- <template v-if="tooltip.type === 'deployments'">
- <template slot="tooltip-title">
+ <template #tooltip-title>
+ <template v-if="tooltip.type === 'deployments'">
{{ __('Deployed') }}
</template>
- <div slot="tooltip-content" class="d-flex align-items-center">
+ <div v-else class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template #tooltip-content>
+ <div v-if="tooltip.type === 'deployments'" class="d-flex align-items-center">
<gl-icon name="commit" class="mr-2" />
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
- </template>
- <template v-else>
- <template slot="tooltip-title">
- <div class="text-nowrap">
- {{ tooltip.title }}
- </div>
- </template>
- <template slot="tooltip-content" :tooltip="tooltip">
+ <template v-else>
<div
v-for="(content, key) in tooltip.content"
:key="key"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
index 94cfb562ce3..8e5a0b5cda2 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
@@ -138,10 +138,10 @@ export default {
</script>
<template>
- <!--
+ <!--
This component should be replaced with a variant developed
as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
- The variant will create a dropdown with an icon, no text and no caret
+ The variant will create a dropdown with an icon, no text and no caret
-->
<gl-dropdown
v-gl-tooltip
@@ -177,20 +177,22 @@ export default {
@formValidation="setFormValidity"
/>
</form>
- <div slot="modal-footer">
- <gl-button @click="hideAddMetricModal">
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- v-track-event="getAddMetricTrackingOptions()"
- data-testid="add-metric-modal-submit-button"
- :disabled="!customMetricsFormIsValid"
- variant="success"
- @click="submitCustomMetricsForm"
- >
- {{ __('Save changes') }}
- </gl-button>
- </div>
+ <template #modal-footer>
+ <div>
+ <gl-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ v-track-event="getAddMetricTrackingOptions()"
+ data-testid="add-metric-modal-submit-button"
+ :disabled="!customMetricsFormIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 05b5b760f0a..f53f78a3f13 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -197,7 +197,7 @@ export default {
<gl-dropdown-section-header>{{ __('Environment') }}</gl-dropdown-section-header>
<gl-search-box-by-type @input="debouncedEnvironmentsSearch" />
- <gl-loading-icon v-if="environmentsLoading" :inline="true" />
+ <gl-loading-icon v-if="environmentsLoading" size="sm" :inline="true" />
<div v-else class="flex-fill overflow-auto">
<gl-dropdown-item
v-for="environment in filteredEnvironments"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 202d18ac721..b786d015f3b 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -389,7 +389,7 @@ export default {
/>
<div class="flex-grow-1"></div>
<div v-if="graphDataIsLoading" class="mx-1 mt-1">
- <gl-loading-icon />
+ <gl-loading-icon size="sm" />
</div>
<div
v-if="isContextualMenuShown"
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
index 49d7e3a48a7..fd07a41ec37 100644
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
@@ -88,7 +88,7 @@ export default {
@change="formChange"
/>
<template #modal-ok>
- <gl-loading-icon v-if="loading" inline color="light" />
+ <gl-loading-icon v-if="loading" size="sm" inline color="light" />
{{ okButtonText }}
</template>
</gl-modal>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index ecb8ef4a0d0..5b73fb4e10d 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -61,7 +61,7 @@ export default {
<div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
- <gl-loading-icon v-if="isLoading" name="loading" />
+ <gl-loading-icon v-if="isLoading" size="sm" name="loading" />
<a
data-testid="group-toggle-button"
:aria-label="__('Toggle collapse')"
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
index 094590804c1..e00c2abfbef 100644
--- a/app/assets/javascripts/namespaces/leave_by_url.js
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -1,6 +1,6 @@
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import { getParameterByName } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
import { initRails } from '~/lib/utils/rails_ujs';
+import { getParameterByName } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
const PARAMETER_NAME = 'leave';
@@ -18,8 +18,10 @@ export default function leaveByUrl(namespaceType) {
if (leaveLink) {
leaveLink.click();
} else {
- Flash(
- sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType }),
- );
+ createFlash({
+ message: sprintf(__('You do not have permission to leave this %{namespaceType}.'), {
+ namespaceType,
+ }),
+ });
}
}
diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
index cac8fecb6b1..97856eaf256 100644
--- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
+++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
@@ -72,7 +72,7 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-stretch">
<div
- class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-py-3 gl-px-5"
+ class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10 gl-p-3"
:class="menuClass"
data-testid="menu-sidebar"
>
@@ -81,7 +81,7 @@ export default {
<keep-alive-slots
v-show="activeView"
:slot-key="activeView"
- class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5"
+ class="gl-w-grid-size-40 gl-overflow-hidden gl-p-3"
data-testid="menu-subview"
data-qa-selector="menu_subview_container"
>
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
index 08b2fbf2ed1..07c6fa7773a 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_item.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
@@ -42,7 +42,7 @@ export default {
v-on="$listeners"
>
<span class="gl-display-flex">
- <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-2!': !iconOnly }" />
+ <gl-icon v-if="menuItem.icon" :name="menuItem.icon" :class="{ 'gl-mr-3!': !iconOnly }" />
<template v-if="!iconOnly">
{{ menuItem.title }}
<gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
index 442af512350..b8555df53df 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
@@ -1,7 +1,7 @@
<script>
import TopNavMenuItem from './top_nav_menu_item.vue';
-const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
+const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-50';
export default {
components: {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index a7fcce02ab3..0f4cec67ce8 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -3,7 +3,7 @@
import katex from 'katex';
import marked from 'marked';
import { sanitize } from '~/lib/dompurify';
-import { hasContent } from '~/lib/utils/text_utility';
+import { hasContent, markdownConfig } from '~/lib/utils/text_utility';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
@@ -140,63 +140,7 @@ export default {
markdown() {
renderer.attachments = this.cell.attachments;
- return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), {
- // allowedTags from GitLab's inline HTML guidelines
- // https://docs.gitlab.com/ee/user/markdown.html#inline-html
- ALLOWED_TAGS: [
- 'a',
- 'abbr',
- 'b',
- 'blockquote',
- 'br',
- 'code',
- 'dd',
- 'del',
- 'div',
- 'dl',
- 'dt',
- 'em',
- 'h1',
- 'h2',
- 'h3',
- 'h4',
- 'h5',
- 'h6',
- 'hr',
- 'i',
- 'img',
- 'ins',
- 'kbd',
- 'li',
- 'ol',
- 'p',
- 'pre',
- 'q',
- 'rp',
- 'rt',
- 'ruby',
- 's',
- 'samp',
- 'span',
- 'strike',
- 'strong',
- 'sub',
- 'summary',
- 'sup',
- 'table',
- 'tbody',
- 'td',
- 'tfoot',
- 'th',
- 'thead',
- 'tr',
- 'tt',
- 'ul',
- 'var',
- ],
- ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
- ALLOW_DATA_ATTR: false,
- });
+ return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig);
},
},
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index c324c846f47..ef51587734d 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -358,7 +358,7 @@ export default class Notes {
setupNewNote($note) {
// Update datetime format on the recent note
- localTimeAgo($note.find('.js-timeago'), false);
+ localTimeAgo($note.find('.js-timeago').get(), false);
this.collapseLongCommitList();
this.taskList.init();
@@ -511,7 +511,7 @@ export default class Notes {
Notes.animateAppendNote(noteEntity.html, discussionContainer);
}
- localTimeAgo($('.js-timeago'), false);
+ localTimeAgo(document.querySelectorAll('.js-timeago'), false);
Notes.checkMergeRequestStatus();
return this.updateNotesCount(1);
}
@@ -628,7 +628,6 @@ export default class Notes {
message: __(
'Your comment could not be submitted! Please check your network connection and try again.',
),
- type: 'alert',
parent: formParentTimeline.get(0),
});
}
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 7213658bdf2..9504ed78778 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -14,7 +14,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import httpStatusCodes from '~/lib/utils/http_status';
import {
@@ -293,7 +293,11 @@ export default {
toggleState()
.then(() => statusBoxState.updateStatus && statusBoxState.updateStatus())
.then(refreshUserMergeRequestCounts)
- .catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState]));
+ .catch(() =>
+ createFlash({
+ message: constants.toggleStateErrorMessage[this.noteableType][this.openState],
+ }),
+ );
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index dfe2763d8bd..0892276ff3b 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -130,15 +130,18 @@ export default {
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
- <note-edited-text
- v-if="discussion.resolved"
- slot="discussion-resolved-text"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
- />
- <slot slot="avatar-badge" name="avatar-badge"></slot>
+ <template #discussion-resolved-text>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
+ />
+ </template>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
</component>
<discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion">
<toggle-replies-widget
@@ -175,7 +178,9 @@ export default {
:discussion-resolve-path="discussion.resolve_path"
@handleDeleteNote="$emit('deleteNote')"
>
- <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
+ <template #avatar-badge>
+ <slot v-if="index === 0" name="avatar-badge"></slot>
+ </template>
</component>
<slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 0f72b4f2dba..44d0c741d5a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui
import { mapActions, mapGetters } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
@@ -234,7 +234,11 @@ export default {
assignee_ids: assignees.map((assignee) => assignee.id),
})
.then(() => this.handleAssigneeUpdate(assignees))
- .catch(() => flash(__('Something went wrong while updating assignees')));
+ .catch(() =>
+ createFlash({
+ message: __('Something went wrong while updating assignees'),
+ }),
+ );
}
},
setAwardEmoji(awardName) {
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 9eb7b928ea4..835750cc137 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,8 +1,8 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import AwardsList from '~/vue_shared/components/awards_list.vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
export default {
components: {
@@ -48,7 +48,11 @@ export default {
awardName,
};
- this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.')));
+ this.toggleAwardRequest(data).catch(() =>
+ createFlash({
+ message: __('Something went wrong on our end.'),
+ }),
+ );
},
},
};
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 6932af61c69..1a4a6c137a6 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -216,6 +216,7 @@ export default {
<gl-loading-icon
v-if="showSpinner"
ref="spinner"
+ size="sm"
class="editing-spinner"
:label="__('Comment is being updated')"
/>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 1af9e4be373..b99579fb9a7 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -2,11 +2,12 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import DraftNote from '~/batch_comments/components/draft_note.vue';
+import createFlash from '~/flash';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
@@ -85,7 +86,7 @@ export default {
return this.getUserData;
},
isLoggedIn() {
- return Boolean(gon.current_user_id);
+ return isLoggedIn();
},
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
@@ -220,7 +221,10 @@ export default {
const msg = __(
'Your comment could not be submitted! Please check your network connection and try again.',
);
- Flash(msg, 'alert', this.$el);
+ createFlash({
+ message: msg,
+ parent: this.$el,
+ });
this.$refs.noteForm.note = noteText;
callback(err);
});
@@ -262,7 +266,9 @@ export default {
@startReplying="showReplyForm"
@deleteNote="deleteNoteHandler"
>
- <slot slot="avatar-badge" name="avatar-badge"></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
<template #footer="{ showReplies }">
<draft-note
v-if="showDraft(discussion.reply_id)"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 0feb77be653..5ea431224ce 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -4,15 +4,16 @@ import $ from 'jquery';
import { escape, isEmpty } from 'lodash';
import { mapGetters, mapActions } from 'vuex';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
+import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
import { __, s__, sprintf } from '../../locale';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
+import { renderMarkdown } from '../utils';
import {
getStartLineNumber,
getEndLineNumber,
@@ -247,7 +248,9 @@ export default {
this.isDeleting = false;
})
.catch(() => {
- Flash(__('Something went wrong while deleting your note. Please try again.'));
+ createFlash({
+ message: __('Something went wrong while deleting your note. Please try again.'),
+ });
this.isDeleting = false;
});
}
@@ -298,7 +301,7 @@ export default {
this.isRequesting = true;
this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
- this.note.note_html = escape(noteText);
+ this.note.note_html = renderMarkdown(noteText);
this.updateNote(data)
.then(() => {
@@ -316,7 +319,10 @@ export default {
this.setSelectedCommentPositionHover();
this.$nextTick(() => {
const msg = __('Something went wrong while editing your comment. Please try again.');
- Flash(msg, 'alert', this.$el);
+ createFlash({
+ message: msg,
+ parent: this.$el,
+ });
this.recoverNoteContent(noteText);
callback();
});
@@ -387,7 +393,9 @@ export default {
:img-alt="author.name"
:img-size="40"
>
- <slot slot="avatar-badge" name="avatar-badge"></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
</user-avatar-link>
</div>
<div class="timeline-content">
@@ -398,7 +406,9 @@ export default {
:note-id="note.id"
:is-confidential="note.confidential"
>
- <slot slot="note-header-info" name="note-header-info"></slot>
+ <template #note-header-info>
+ <slot name="note-header-info"></slot>
+ </template>
<span v-if="commit" v-safe-html="actionText"></span>
<span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
</note-header>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 433f75a752d..29c60b96d8a 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,13 +1,13 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import draftNote from '../../batch_comments/components/draft_note.vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
@@ -66,6 +66,7 @@ export default {
data() {
return {
currentFilter: null,
+ renderSkeleton: !this.shouldShow,
};
},
computed: {
@@ -93,7 +94,7 @@ export default {
return this.noteableData.noteableType;
},
allDiscussions() {
- if (this.isLoading) {
+ if (this.renderSkeleton || this.isLoading) {
const prerenderedNotesCount = parseInt(this.notesData.prerenderedNotesCount, 10) || 0;
return new Array(prerenderedNotesCount).fill({
@@ -122,6 +123,10 @@ export default {
if (!this.isNotesFetched) {
this.fetchNotes();
}
+
+ setTimeout(() => {
+ this.renderSkeleton = !this.shouldShow;
+ });
},
discussionTabCounterText(val) {
if (this.discussionsCount) {
@@ -216,7 +221,9 @@ export default {
.catch(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
- Flash(__('Something went wrong while fetching comments. Please try again.'));
+ createFlash({
+ message: __('Something went wrong while fetching comments. Please try again.'),
+ });
});
},
initPolling() {
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 27ed8e203b0..9783def1b46 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
@@ -46,7 +46,10 @@ export default {
this.isResolving = false;
const msg = __('Something went wrong while resolving this discussion. Please try again.');
- Flash(msg, 'alert', this.$el);
+ createFlash({
+ message: msg,
+ parent: this.$el,
+ });
});
},
},
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 086e9122c60..6a4a3263e4a 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import Api from '~/api';
+import createFlash from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
@@ -9,7 +10,6 @@ import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_co
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import loadAwardsHandler from '../../awards_handler';
-import { deprecatedCreateFlash as Flash } from '../../flash';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import Poll from '../../lib/utils/poll';
import { create } from '../../lib/utils/recurrence';
@@ -312,25 +312,23 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
$('.notes-form .flash-container').hide(); // hide previous flash notification
commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
- if (replyId) {
- if (hasQuickActions) {
- placeholderText = utils.stripQuickActions(placeholderText);
- }
+ if (hasQuickActions) {
+ placeholderText = utils.stripQuickActions(placeholderText);
+ }
- if (placeholderText.length) {
- commit(types.SHOW_PLACEHOLDER_NOTE, {
- noteBody: placeholderText,
- replyId,
- });
- }
+ if (placeholderText.length) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ noteBody: placeholderText,
+ replyId,
+ });
+ }
- if (hasQuickActions) {
- commit(types.SHOW_PLACEHOLDER_NOTE, {
- isSystemNote: true,
- noteBody: utils.getQuickActionText(note),
- replyId,
- });
- }
+ if (hasQuickActions) {
+ commit(types.SHOW_PLACEHOLDER_NOTE, {
+ isSystemNote: true,
+ noteBody: utils.getQuickActionText(note),
+ replyId,
+ });
}
const processQuickActions = (res) => {
@@ -354,7 +352,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
+ createFlash({
+ message: message || __('Commands applied'),
+ type: 'notice',
+ parent: noteData.flashContainer,
+ });
}
return res;
@@ -375,11 +377,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
awardsHandler.scrollToAwards();
})
.catch(() => {
- Flash(
- __('Something went wrong while adding your award. Please try again.'),
- 'alert',
- noteData.flashContainer,
- );
+ createFlash({
+ message: __('Something went wrong while adding your award. Please try again.'),
+ parent: noteData.flashContainer,
+ });
})
.then(() => res);
};
@@ -397,9 +398,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
};
const removePlaceholder = (res) => {
- if (replyId) {
- commit(types.REMOVE_PLACEHOLDER_NOTES);
- }
+ commit(types.REMOVE_PLACEHOLDER_NOTES);
return res;
};
@@ -417,7 +416,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), {
error: base[0].toLowerCase(),
});
- Flash(errorMsg, 'alert', noteData.flashContainer);
+ createFlash({
+ message: errorMsg,
+ parent: noteData.flashContainer,
+ });
return { ...data, hasFlash: true };
}
}
@@ -480,7 +482,9 @@ export const poll = ({ commit, state, getters, dispatch }) => {
});
notePollOccurrenceTracking.handle(2, () => {
// On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error)
- flashContainer = Flash(__('Something went wrong while fetching latest comments.'));
+ flashContainer = createFlash({
+ message: __('Something went wrong while fetching latest comments.'),
+ });
setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL);
});
@@ -570,7 +574,9 @@ export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter })
.catch(() => {
dispatch('setLoadingState', false);
dispatch('setNotesFetchedState', true);
- Flash(__('Something went wrong while fetching comments. Please try again.'));
+ createFlash({
+ message: __('Something went wrong while fetching comments. Please try again.'),
+ });
});
};
@@ -613,7 +619,10 @@ export const submitSuggestion = (
const flashMessage = errorMessage || defaultMessage;
- Flash(__(flashMessage), 'alert', flashContainer);
+ createFlash({
+ message: __(flashMessage),
+ parent: flashContainer,
+ });
})
.finally(() => {
commit(types.SET_RESOLVING_DISCUSSION, false);
@@ -646,7 +655,10 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai
const flashMessage = errorMessage || defaultMessage;
- Flash(__(flashMessage), 'alert', flashContainer);
+ createFlash({
+ message: __(flashMessage),
+ parent: flashContainer,
+ });
})
.finally(() => {
commit(types.SET_APPLYING_BATCH_STATE, false);
@@ -685,7 +697,9 @@ export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersio
})
.catch((error) => {
dispatch('receiveDescriptionVersionError', error);
- Flash(__('Something went wrong while fetching description changes. Please try again.'));
+ createFlash({
+ message: __('Something went wrong while fetching description changes. Please try again.'),
+ });
});
};
@@ -717,7 +731,9 @@ export const softDeleteDescriptionVersion = (
})
.catch((error) => {
dispatch('receiveDeleteDescriptionVersionError', error);
- Flash(__('Something went wrong while deleting description changes. Please try again.'));
+ createFlash({
+ message: __('Something went wrong while deleting description changes. Please try again.'),
+ });
// Throw an error here because a component like SystemNote -
// needs to know if the request failed to reset its internal state.
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index b04b1d28ffa..956221d69ae 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -279,7 +279,7 @@ export const getDiscussion = (state) => (discussionId) =>
export const commentsDisabled = (state) => state.commentsDisabled;
export const suggestionsCount = (state, getters) =>
- Object.values(getters.notesById).filter((n) => n.suggestions.length).length;
+ Object.values(getters.notesById).filter((n) => n.suggestions?.length).length;
export const hasDrafts = (state, getters, rootState, rootGetters) =>
Boolean(rootGetters['batchComments/hasDrafts']);
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index 7966a884eab..ec18a570960 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,4 +1,7 @@
/* eslint-disable @gitlab/require-i18n-strings */
+import marked from 'marked';
+import { sanitize } from '~/lib/dompurify';
+import { markdownConfig } from '~/lib/utils/text_utility';
/**
* Tracks snowplow event when User toggles timeline view
@@ -10,3 +13,7 @@ export const trackToggleTimelineView = (enabled) => ({
label: 'Status',
property: enabled,
});
+
+export const renderMarkdown = (rawMarkdown) => {
+ return sanitize(marked(rawMarkdown), markdownConfig);
+};
diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
index 2b5cff35fc8..182948c39f4 100644
--- a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
+++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue
@@ -73,7 +73,7 @@ export default {
this.events = this.buildEvents(events);
} catch (error) {
- this.$toast.show(this.$options.i18n.loadNotificationLevelErrorMessage, { type: 'error' });
+ this.$toast.show(this.$options.i18n.loadNotificationLevelErrorMessage);
} finally {
this.isLoading = false;
}
@@ -93,7 +93,7 @@ export default {
this.events = this.buildEvents(events);
} catch (error) {
- this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
+ this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage);
}
},
},
@@ -132,7 +132,7 @@ export default {
@change="updateEvent($event, event)"
>
<strong>{{ event.name }}</strong
- ><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" />
+ ><gl-loading-icon v-if="event.loading" size="sm" :inline="true" class="gl-ml-2" />
</gl-form-checkbox>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
index 4963b9386c1..69eb2115bf4 100644
--- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue
+++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue
@@ -104,7 +104,7 @@ export default {
this.selectedNotificationLevel = level;
this.openNotificationsModal();
} catch (error) {
- this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
+ this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage);
} finally {
this.isLoading = false;
}
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
index 969904bc6d0..529eb7d207b 100644
--- a/app/assets/javascripts/operation_settings/store/actions.js
+++ b/app/assets/javascripts/operation_settings/store/actions.js
@@ -37,6 +37,5 @@ export const receiveSaveChangesError = (_, error) => {
createFlash({
message: `${__('There was an error saving your changes.')} ${message}`,
- type: 'alert',
});
};
diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue
index 55ffe10a608..59da32e6666 100644
--- a/app/assets/javascripts/packages/details/components/app.vue
+++ b/app/assets/javascripts/packages/details/components/app.vue
@@ -11,8 +11,8 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { objectToQueryString } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { objectToQuery } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import PackageListRow from '../../shared/components/package_list_row.vue';
@@ -114,7 +114,7 @@ export default {
!this.groupListUrl || document.referrer.includes(this.projectName)
? this.projectListUrl
: this.groupListUrl; // to avoid security issue url are supplied from backend
- const modalQuery = objectToQueryString({ [SHOW_DELETE_SUCCESS_ALERT]: true });
+ const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
window.location.replace(`${returnTo}?${modalQuery}`);
},
handleFileDelete(file) {
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index d871c2e4d24..2c6fd94024e 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -86,6 +86,14 @@ export const PACKAGE_TYPES = [
title: s__('PackageRegistry|RubyGems'),
type: PackageType.RUBYGEMS,
},
+ {
+ title: s__('PackageRegistry|Debian'),
+ type: PackageType.DEBIAN,
+ },
+ {
+ title: s__('PackageRegistry|Helm'),
+ type: PackageType.HELM,
+ },
];
export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index 0ef6a3d0d12..b4cdca34d92 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -9,6 +9,8 @@ export const PackageType = {
COMPOSER: 'composer',
RUBYGEMS: 'rubygems',
GENERIC: 'generic',
+ DEBIAN: 'debian',
+ HELM: 'helm',
};
// we want this separated from the main dictionary to avoid it being pulled in the search of package
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index bd35a47ca4d..7e86e5b2991 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -25,6 +25,10 @@ export const getPackageTypeLabel = (packageType) => {
return s__('PackageRegistry|Composer');
case PackageType.GENERIC:
return s__('PackageRegistry|Generic');
+ case PackageType.DEBIAN:
+ return s__('PackageRegistry|Debian');
+ case PackageType.HELM:
+ return s__('PackageRegistry|Helm');
default:
return null;
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
new file mode 100644
index 00000000000..e2a2fb1430d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
@@ -0,0 +1,301 @@
+<script>
+/*
+ * The commented part of this component needs to be re-enabled in the refactor process,
+ * See here for more info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64939
+ */
+import {
+ GlBadge,
+ GlButton,
+ GlModal,
+ GlModalDirective,
+ GlTooltipDirective,
+ GlEmptyState,
+ GlTab,
+ GlTabs,
+ GlSprintf,
+} from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import { s__, __ } from '~/locale';
+// import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue';
+// import DependencyRow from '~/packages/details/components/dependency_row.vue';
+// import InstallationCommands from '~/packages/details/components/installation_commands.vue';
+// import PackageFiles from '~/packages/details/components/package_files.vue';
+// import PackageHistory from '~/packages/details/components/package_history.vue';
+// import PackageListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import {
+ PackageType,
+ TrackingActions,
+ SHOW_DELETE_SUCCESS_ALERT,
+} from '~/packages/shared/constants';
+import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import Tracking from '~/tracking';
+
+export default {
+ name: 'PackagesApp',
+ components: {
+ GlBadge,
+ GlButton,
+ GlEmptyState,
+ GlModal,
+ GlTab,
+ GlTabs,
+ GlSprintf,
+ PackageTitle: () => import('~/packages/details/components/package_title.vue'),
+ TerraformTitle: () =>
+ import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'),
+ PackagesListLoader,
+ // PackageListRow,
+ // DependencyRow,
+ // PackageHistory,
+ // AdditionalMetadata,
+ // InstallationCommands,
+ // PackageFiles,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ mixins: [Tracking.mixin()],
+ inject: [
+ 'titleComponent',
+ 'projectName',
+ 'canDelete',
+ 'svgPath',
+ 'npmPath',
+ 'npmHelpPath',
+ 'projectListUrl',
+ 'groupListUrl',
+ ],
+ trackingActions: { ...TrackingActions },
+ data() {
+ return {
+ fileToDelete: null,
+ packageEntity: {},
+ };
+ },
+ computed: {
+ packageFiles() {
+ return this.packageEntity.packageFiles;
+ },
+ isLoading() {
+ return false;
+ },
+ isValidPackage() {
+ return Boolean(this.packageEntity.name);
+ },
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageEntity.package_type),
+ };
+ },
+ hasVersions() {
+ return this.packageEntity.versions?.length > 0;
+ },
+ packageDependencies() {
+ return this.packageEntity.dependency_links || [];
+ },
+ showDependencies() {
+ return this.packageEntity.package_type === PackageType.NUGET;
+ },
+ showFiles() {
+ return this.packageEntity?.package_type !== PackageType.COMPOSER;
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ getPackageVersions() {
+ if (!this.packageEntity.versions) {
+ // this.fetchPackageVersions();
+ }
+ },
+ async confirmPackageDeletion() {
+ this.track(TrackingActions.DELETE_PACKAGE);
+
+ await this.deletePackage();
+
+ const returnTo =
+ !this.groupListUrl || document.referrer.includes(this.projectName)
+ ? this.projectListUrl
+ : this.groupListUrl; // to avoid security issue url are supplied from backend
+
+ const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
+
+ window.location.replace(`${returnTo}?${modalQuery}`);
+ },
+ handleFileDelete(file) {
+ this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE);
+ this.fileToDelete = { ...file };
+ this.$refs.deleteFileModal.show();
+ },
+ confirmFileDelete() {
+ this.track(TrackingActions.DELETE_PACKAGE_FILE);
+ // this.deletePackageFile(this.fileToDelete.id);
+ this.fileToDelete = null;
+ },
+ },
+ i18n: {
+ deleteModalTitle: s__(`PackageRegistry|Delete Package Version`),
+ deleteModalContent: s__(
+ `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
+ ),
+ deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
+ deleteFileModalContent: s__(
+ `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
+ ),
+ },
+ modal: {
+ packageDeletePrimaryAction: {
+ text: __('Delete'),
+ attributes: [
+ { variant: 'danger' },
+ { category: 'primary' },
+ { 'data-qa-selector': 'delete_modal_button' },
+ ],
+ },
+ fileDeletePrimaryAction: {
+ text: __('Delete'),
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
+ cancelAction: {
+ text: __('Cancel'),
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ v-if="!isValidPackage"
+ :title="s__('PackageRegistry|Unable to load package')"
+ :description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
+ :svg-path="svgPath"
+ />
+
+ <div v-else class="packages-app">
+ <component :is="titleComponent">
+ <template #delete-button>
+ <gl-button
+ v-if="canDelete"
+ v-gl-modal="'delete-modal'"
+ class="js-delete-button"
+ variant="danger"
+ category="primary"
+ data-qa-selector="delete_button"
+ >
+ {{ __('Delete') }}
+ </gl-button>
+ </template>
+ </component>
+
+ <gl-tabs>
+ <gl-tab :title="__('Detail')">
+ <div data-qa-selector="package_information_content">
+ <!-- <package-history :package-entity="packageEntity" :project-name="projectName" />
+
+ <installation-commands
+ :package-entity="packageEntity"
+ :npm-path="npmPath"
+ :npm-help-path="npmHelpPath"
+ />
+
+ <additional-metadata :package-entity="packageEntity" /> -->
+ </div>
+
+ <!-- <package-files
+ v-if="showFiles"
+ :package-files="packageFiles"
+ :can-delete="canDelete"
+ @download-file="track($options.trackingActions.PULL_PACKAGE)"
+ @delete-file="handleFileDelete"
+ /> -->
+ </gl-tab>
+
+ <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
+ <template #title>
+ <span>{{ __('Dependencies') }}</span>
+ <gl-badge size="sm" data-testid="dependencies-badge">{{
+ packageDependencies.length
+ }}</gl-badge>
+ </template>
+
+ <template v-if="packageDependencies.length > 0">
+ <dependency-row
+ v-for="(dep, index) in packageDependencies"
+ :key="index"
+ :dependency="dep"
+ />
+ </template>
+
+ <p v-else class="gl-mt-3" data-testid="no-dependencies-message">
+ {{ s__('PackageRegistry|This NuGet package has no dependencies.') }}
+ </p>
+ </gl-tab>
+
+ <gl-tab
+ :title="__('Other versions')"
+ title-item-class="js-versions-tab"
+ @click="getPackageVersions"
+ >
+ <template v-if="isLoading && !hasVersions">
+ <packages-list-loader />
+ </template>
+
+ <template v-else-if="hasVersions">
+ <!-- <package-list-row
+ v-for="v in packageEntity.versions"
+ :key="v.id"
+ :package-entity="{ name: packageEntity.name, ...v }"
+ :package-link="v.id.toString()"
+ :disable-delete="true"
+ :show-package-type="false"
+ /> -->
+ </template>
+
+ <p v-else class="gl-mt-3" data-testid="no-versions-message">
+ {{ s__('PackageRegistry|There are no other versions of this package.') }}
+ </p>
+ </gl-tab>
+ </gl-tabs>
+
+ <gl-modal
+ ref="deleteModal"
+ class="js-delete-modal"
+ modal-id="delete-modal"
+ :action-primary="$options.modal.packageDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ @primary="confirmPackageDeletion"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
+ >
+ <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
+ <gl-sprintf :message="$options.i18n.deleteModalContent">
+ <template #version>
+ <strong>{{ packageEntity.version }}</strong>
+ </template>
+
+ <template #name>
+ <strong>{{ packageEntity.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+
+ <gl-modal
+ ref="deleteFileModal"
+ modal-id="delete-file-modal"
+ :action-primary="$options.modal.fileDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ @primary="confirmFileDelete"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
+ >
+ <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
+ <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent">
+ <template #filename>
+ <strong>{{ fileToDelete.file_name }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js
new file mode 100644
index 00000000000..309b35a8084
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import PackagesApp from '../components/details/app.vue';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-vue-packages-detail-new');
+ if (!el) {
+ return null;
+ }
+
+ const { canDelete, ...datasetOptions } = el.dataset;
+ return new Vue({
+ el,
+ provide: {
+ canDelete: parseBoolean(canDelete),
+ titleComponent: 'PackageTitle',
+ ...datasetOptions,
+ },
+ render(createElement) {
+ return createElement(PackagesApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 01d4861f5c2..ec3be43196c 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -86,7 +86,7 @@ export default {
this.alertMessage = ERROR_UPDATING_SETTINGS;
} else {
this.dismissAlert();
- this.$toast.show(SUCCESS_UPDATING_SETTINGS, { type: 'success' });
+ this.$toast.show(SUCCESS_UPDATING_SETTINGS);
}
})
.catch((e) => {
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index a2256c5c371..d29489a0b33 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -3,19 +3,19 @@ import { s__, __ } from '~/locale';
export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Package Registry');
export const PACKAGE_SETTINGS_DESCRIPTION = s__(
- 'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}',
+ 'PackageRegistry|Use GitLab as a private registry for common package formats. %{linkStart}Learn more.%{linkEnd}',
);
export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
export const DUPLICATES_ALLOWED_DISABLED = s__(
- 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.',
+ 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Reject packages with the same name and version.',
);
export const DUPLICATES_ALLOWED_ENABLED = s__(
- 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Packages with the same name and version are accepted.',
+ 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Accept packages with the same name and version.',
);
export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
- 'PackageRegistry|Packages can be published if their name or version matches this regex',
+ 'PackageRegistry|Publish packages if their name or version matches this regex.',
);
export const SUCCESS_UPDATING_SETTINGS = s__('PackageRegistry|Settings saved successfully');
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
index 41be70a3ad5..6030af9d2c3 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
@@ -88,8 +88,6 @@ export default {
return {
...this.value,
cadence: this.findDefaultOption('cadence'),
- keepN: this.findDefaultOption('keepN'),
- olderThan: this.findDefaultOption('olderThan'),
};
},
showLoadingIcon() {
@@ -158,14 +156,14 @@ export default {
.then(({ data }) => {
const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
if (errorMessage) {
- this.$toast.show(errorMessage, { type: 'error' });
+ this.$toast.show(errorMessage);
} else {
- this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
}
})
.catch((error) => {
this.setApiErrors(error);
- this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE);
})
.finally(() => {
this.mutationLoading = false;
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
index 4a2d7c7d466..b577a051862 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
@@ -11,11 +11,14 @@ export const olderThanTranslationGenerator = (variable) => n__('%d day', '%d day
export const keepNTranslationGenerator = (variable) =>
n__('%d tag per image name', '%d tags per image name', variable);
-export const optionLabelGenerator = (collection, translationFn) =>
- collection.map((option) => ({
+export const optionLabelGenerator = (collection, translationFn) => {
+ const result = collection.map((option) => ({
...option,
label: translationFn(option.variable),
}));
+ result.unshift({ key: null, label: '' });
+ return result;
+};
export const formOptionsGenerator = () => {
return {
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 3ad9d80b4f2..aa2f539b6e2 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -1,8 +1,7 @@
import $ from 'jquery';
import 'vendor/jquery.endless-scroll';
import axios from '~/lib/utils/axios_utils';
-import { getParameterByName } from '~/lib/utils/common_utils';
-import { removeParams } from '~/lib/utils/url_utility';
+import { removeParams, getParameterByName } from '~/lib/utils/url_utility';
const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index a88d35796f7..ab29f9149f7 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -5,4 +5,4 @@ import AbuseReports from './abuse_reports';
new AbuseReports(); /* eslint-disable-line no-new */
new UsersSelect(); /* eslint-disable-line no-new */
-document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
+initDeprecatedRemoveRowBehavior();
diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
index a2fca238613..a5305777dd5 100644
--- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
@@ -1,3 +1,3 @@
import setup from '~/admin/application_settings/setup_metrics_and_profiling';
-document.addEventListener('DOMContentLoaded', setup);
+setup();
diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
new file mode 100644
index 00000000000..bf27b1a81ff
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
@@ -0,0 +1,41 @@
+import { __ } from '~/locale';
+
+export const HELPER_TEXT_SERVICE_PING_DISABLED = __(
+ 'To enable Registration Features, make sure "Enable service ping" is checked.',
+);
+
+export const HELPER_TEXT_SERVICE_PING_ENABLED = __(
+ 'You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.',
+);
+
+function setHelperText(usagePingCheckbox) {
+ const helperTextId = document.getElementById('service_ping_features_helper_text');
+
+ const usagePingFeaturesLabel = document.getElementById('service_ping_features_label');
+
+ const usagePingFeaturesCheckbox = document.getElementById(
+ 'application_setting_usage_ping_features_enabled',
+ );
+
+ helperTextId.textContent = usagePingCheckbox.checked
+ ? HELPER_TEXT_SERVICE_PING_ENABLED
+ : HELPER_TEXT_SERVICE_PING_DISABLED;
+
+ usagePingFeaturesLabel.classList.toggle('gl-cursor-not-allowed', !usagePingCheckbox.checked);
+
+ usagePingFeaturesCheckbox.disabled = !usagePingCheckbox.checked;
+
+ if (!usagePingCheckbox.checked) {
+ usagePingFeaturesCheckbox.disabled = true;
+ usagePingFeaturesCheckbox.checked = false;
+ }
+}
+
+export default function initSetHelperText() {
+ const usagePingCheckbox = document.getElementById('application_setting_usage_ping_enabled');
+
+ setHelperText(usagePingCheckbox);
+ usagePingCheckbox.addEventListener('change', () => {
+ setHelperText(usagePingCheckbox);
+ });
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index bc1d4dd6122..08f6633f424 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '../../../flash';
+import createFlash from '~/flash';
import axios from '../../../lib/utils/axios_utils';
import { __ } from '../../../locale';
@@ -38,7 +38,9 @@ export default class PayloadPreviewer {
})
.catch(() => {
this.spinner.classList.remove('d-inline-flex');
- flash(__('Error fetching payload data.'));
+ createFlash({
+ message: __('Error fetching payload data.'),
+ });
});
}
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index 5a16716fe2d..2a7e6a45cdd 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -1,8 +1,9 @@
import $ from 'jquery';
import { debounce } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { textColorForBackground } from '~/lib/utils/color_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
export default () => {
@@ -30,7 +31,11 @@ export default () => {
.then(({ data }) => {
$jsBroadcastMessagePreview.html(data.message);
})
- .catch(() => flash(__('An error occurred while rendering preview broadcast message')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while rendering preview broadcast message'),
+ }),
+ );
}
};
@@ -61,7 +66,7 @@ export default () => {
'input',
debounce(() => {
reloadPreview();
- }, 250),
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
);
const updateColorPreview = () => {
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
index b7db6443658..f687423594d 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
@@ -1,7 +1,5 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import initBroadcastMessagesForm from './broadcast_message';
-document.addEventListener('DOMContentLoaded', () => {
- initBroadcastMessagesForm();
- initDeprecatedRemoveRowBehavior();
-});
+initBroadcastMessagesForm();
+initDeprecatedRemoveRowBehavior();
diff --git a/app/assets/javascripts/pages/admin/clusters/destroy/index.js b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/admin/clusters/destroy/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/clusters/edit/index.js b/app/assets/javascripts/pages/admin/clusters/edit/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/admin/clusters/edit/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/edit/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js
index 4d04c37caa7..f398b1cee82 100644
--- a/app/assets/javascripts/pages/admin/clusters/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/index.js
@@ -1,5 +1,3 @@
import initCreateCluster from '~/create_cluster/init_create_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initCreateCluster(document, gon);
-});
+initCreateCluster(document, gon);
diff --git a/app/assets/javascripts/pages/admin/clusters/index/index.js b/app/assets/javascripts/pages/admin/clusters/index/index.js
index a99e0dfa4f0..a1ba920b322 100644
--- a/app/assets/javascripts/pages/admin/clusters/index/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/index/index.js
@@ -1,8 +1,6 @@
import initClustersListApp from '~/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
-document.addEventListener('DOMContentLoaded', () => {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
- initClustersListApp();
-});
+const callout = document.querySelector('.gcp-signup-offer');
+PersistentUserCallout.factory(callout);
+initClustersListApp();
diff --git a/app/assets/javascripts/pages/admin/clusters/new/index.js b/app/assets/javascripts/pages/admin/clusters/new/index.js
index 876bab0b339..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/admin/clusters/new/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/new/index.js
@@ -1,5 +1,3 @@
import initNewCluster from '~/clusters/new_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initNewCluster();
-});
+initNewCluster();
diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js
index 9d94973af0d..524b2c6f66a 100644
--- a/app/assets/javascripts/pages/admin/clusters/show/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/show/index.js
@@ -2,8 +2,6 @@ import ClustersBundle from '~/clusters/clusters_bundle';
import initIntegrationForm from '~/clusters/forms/show';
import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
- initClusterHealth();
- initIntegrationForm();
-});
+new ClustersBundle(); // eslint-disable-line no-new
+initClusterHealth();
+initIntegrationForm();
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index d6fa1be29b0..a94a60af7ff 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,5 +1,5 @@
import initDevOpsScore from '~/analytics/devops_report/devops_score';
-import initDevOpsScoreDisabledUsagePing from '~/analytics/devops_report/devops_score_disabled_usage_ping';
+import initDevOpsScoreDisabledServicePing from '~/analytics/devops_report/devops_score_disabled_service_ping';
-initDevOpsScoreDisabledUsagePing();
+initDevOpsScoreDisabledServicePing();
initDevOpsScore();
diff --git a/app/assets/javascripts/pages/admin/identities/index.js b/app/assets/javascripts/pages/admin/identities/index.js
new file mode 100644
index 00000000000..a9f5f00cb9b
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/identities/index.js
@@ -0,0 +1,6 @@
+import { initAdminUserActions, initDeleteUserModals } from '~/admin/users';
+import initConfirmModal from '~/confirm_modal';
+
+initAdminUserActions();
+initDeleteUserModals();
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index dc1bb88bf4b..8fbc8dc17bc 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,3 +1,8 @@
import { initExpiresAtField } from '~/access_tokens';
+import { initAdminUserActions, initDeleteUserModals } from '~/admin/users';
+import initConfirmModal from '~/confirm_modal';
+initAdminUserActions();
+initDeleteUserModals();
initExpiresAtField();
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js
index ba4b271f09e..8002fa8bf78 100644
--- a/app/assets/javascripts/pages/admin/integrations/edit/index.js
+++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js
@@ -1,7 +1,7 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
-document.addEventListener('DOMContentLoaded', () => {
+function initIntegrations() {
const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
integrationSettingsForm.init();
@@ -10,4 +10,6 @@ document.addEventListener('DOMContentLoaded', () => {
const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
prometheusMetrics.loadActiveMetrics();
}
-});
+}
+
+initIntegrations();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 46ddb95299d..a4d89889d57 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -5,7 +5,7 @@ import stopJobsModal from './components/stop_jobs_modal.vue';
Vue.use(Translate);
-document.addEventListener('DOMContentLoaded', () => {
+function initJobs() {
const buttonId = 'js-stop-jobs-button';
const modalId = 'stop-jobs-modal';
const stopJobsButton = document.getElementById(buttonId);
@@ -31,4 +31,6 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
}
-});
+}
+
+initJobs();
diff --git a/app/assets/javascripts/pages/admin/keys/index.js b/app/assets/javascripts/pages/admin/keys/index.js
index 45b83ffcd67..868c8e33077 100644
--- a/app/assets/javascripts/pages/admin/keys/index.js
+++ b/app/assets/javascripts/pages/admin/keys/index.js
@@ -1,5 +1,3 @@
import initConfirmModal from '~/confirm_modal';
-document.addEventListener('DOMContentLoaded', () => {
- initConfirmModal();
-});
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js
index 17ee7c03ed6..0ceab3b922f 100644
--- a/app/assets/javascripts/pages/admin/labels/index/index.js
+++ b/app/assets/javascripts/pages/admin/labels/index/index.js
@@ -1,4 +1,4 @@
-document.addEventListener('DOMContentLoaded', () => {
+function initLabels() {
const pagination = document.querySelector('.labels .gl-pagination');
const emptyState = document.querySelector('.labels .nothing-here-block.hidden');
@@ -18,4 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-remove-label').forEach((row) => {
row.addEventListener('ajax:success', removeLabelSuccessCallback);
});
-});
+}
+
+initLabels();
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index b92fc8d125d..055d6f40c14 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -46,7 +46,7 @@ export default {
return sprintf(
s__(`AdminProjects|
You’re about to permanently delete the project %{projectName}, its repository,
- and all related resources including issues, merge requests, etc.. Once you confirm and press
+ and all related resources, including issues and merge requests. Once you confirm and press
%{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
{
projectName: `<strong>${escape(this.projectName)}</strong>`,
diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js
index cc9a9b6cc38..c6cf4a46dba 100644
--- a/app/assets/javascripts/pages/admin/projects/index/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index/index.js
@@ -13,9 +13,11 @@ import deleteProjectModal from './components/delete_project_modal.vue';
const deleteModal = new Vue({
el: deleteProjectModalEl,
- data: {
- deleteProjectUrl: '',
- projectName: '',
+ data() {
+ return {
+ deleteProjectUrl: '',
+ projectName: '',
+ };
},
mounted() {
const deleteProjectButtons = document.querySelectorAll('.delete-project-button');
diff --git a/app/assets/javascripts/pages/admin/spam_logs/index.js b/app/assets/javascripts/pages/admin/spam_logs/index.js
index e5ab5d43bbf..ac850a6467b 100644
--- a/app/assets/javascripts/pages/admin/spam_logs/index.js
+++ b/app/assets/javascripts/pages/admin/spam_logs/index.js
@@ -1,3 +1,3 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
-document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
+initDeprecatedRemoveRowBehavior();
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 9a8b0c9990f..41e99a3baf5 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -1,64 +1,7 @@
-import Vue from 'vue';
-
-import { initAdminUsersApp } from '~/admin/users';
+import { initAdminUsersApp, initDeleteUserModals, initAdminUserActions } from '~/admin/users';
import initConfirmModal from '~/confirm_modal';
-import csrf from '~/lib/utils/csrf';
-import Translate from '~/vue_shared/translate';
-import ModalManager from './components/user_modal_manager.vue';
-
-const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button';
-const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
-const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
-
-function loadModalsConfigurationFromHtml(modalsElement) {
- const modalsConfiguration = {};
-
- if (!modalsElement) {
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- throw new Error('Modals content element not found!');
- }
-
- Array.from(modalsElement.children).forEach((node) => {
- const { modal, ...config } = node.dataset;
- modalsConfiguration[modal] = {
- title: node.dataset.title,
- ...config,
- content: node.innerHTML,
- };
- });
-
- return modalsConfiguration;
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- Vue.use(Translate);
-
- initAdminUsersApp();
-
- const modalConfiguration = loadModalsConfigurationFromHtml(
- document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
- );
-
- // eslint-disable-next-line no-new
- new Vue({
- el: MODAL_MANAGER_SELECTOR,
- functional: true,
- methods: {
- show(...args) {
- this.$refs.manager.show(...args);
- },
- },
- render(h) {
- return h(ModalManager, {
- ref: 'manager',
- props: {
- selector: CONFIRM_DELETE_BUTTON_SELECTOR,
- modalConfiguration,
- csrfToken: csrf.token,
- },
- });
- },
- });
- initConfirmModal();
-});
+initAdminUsersApp();
+initAdminUserActions();
+initDeleteUserModals();
+initConfirmModal();
diff --git a/app/assets/javascripts/pages/admin/users/keys/index.js b/app/assets/javascripts/pages/admin/users/keys/index.js
deleted file mode 100644
index 45b83ffcd67..00000000000
--- a/app/assets/javascripts/pages/admin/users/keys/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import initConfirmModal from '~/confirm_modal';
-
-document.addEventListener('DOMContentLoaded', () => {
- initConfirmModal();
-});
diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js
index b9277106a71..c14848c4798 100644
--- a/app/assets/javascripts/pages/dashboard/groups/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js
@@ -1,5 +1,3 @@
import initGroupsList from '~/groups';
-document.addEventListener('DOMContentLoaded', () => {
- initGroupsList();
-});
+initGroupsList();
diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
index 397149aaa9e..1f3e458fe17 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js
@@ -2,8 +2,6 @@ import Milestone from '~/milestone';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
-document.addEventListener('DOMContentLoaded', () => {
- new Milestone(); // eslint-disable-line no-new
- new Sidebar(); // eslint-disable-line no-new
- new MountMilestoneSidebar(); // eslint-disable-line no-new
-});
+new Milestone(); // eslint-disable-line no-new
+new Sidebar(); // eslint-disable-line no-new
+new MountMilestoneSidebar(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 42341436b55..946076cfb29 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import { getGroups } from '~/api/groups_api';
import { getProjects } from '~/api/projects_api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { isMetaClick } from '~/lib/utils/common_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
@@ -103,7 +103,9 @@ export default class Todos {
})
.catch(() => {
this.updateRowState(target, true);
- return flash(__('Error updating status of to-do item.'));
+ return createFlash({
+ message: __('Error updating status of to-do item.'),
+ });
});
}
@@ -145,7 +147,11 @@ export default class Todos {
this.updateAllState(target, data);
this.updateBadges(data);
})
- .catch(() => flash(__('Error updating status for all to-do items.')));
+ .catch(() =>
+ createFlash({
+ message: __('Error updating status for all to-do items.'),
+ }),
+ );
}
updateAllState(target, data) {
diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js
index 01001d4f3ff..6c9378b7231 100644
--- a/app/assets/javascripts/pages/explore/projects/index.js
+++ b/app/assets/javascripts/pages/explore/projects/index.js
@@ -1,5 +1,3 @@
import ProjectsList from '~/projects_list';
-document.addEventListener('DOMContentLoaded', () => {
- new ProjectsList(); // eslint-disable-line no-new
-});
+new ProjectsList(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/clusters/destroy/index.js b/app/assets/javascripts/pages/groups/clusters/destroy/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/groups/clusters/destroy/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/destroy/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/clusters/edit/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/groups/clusters/edit/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js
index d5ce5d076a2..4d48bd4be2b 100644
--- a/app/assets/javascripts/pages/groups/clusters/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index.js
@@ -1,7 +1,5 @@
import initIntegrationForm from '~/clusters/forms/show/index';
import initCreateCluster from '~/create_cluster/init_create_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initCreateCluster(document, gon);
- initIntegrationForm();
-});
+initCreateCluster(document, gon);
+initIntegrationForm();
diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/new/index.js
index 876bab0b339..de9ded87ef3 100644
--- a/app/assets/javascripts/pages/groups/clusters/new/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/new/index.js
@@ -1,5 +1,3 @@
import initNewCluster from '~/clusters/new_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initNewCluster();
-});
+initNewCluster();
diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js
index ccf631b2c53..5d202a8824f 100644
--- a/app/assets/javascripts/pages/groups/clusters/show/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/show/index.js
@@ -1,7 +1,5 @@
import ClustersBundle from '~/clusters/clusters_bundle';
import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
- initClusterHealth();
-});
+new ClustersBundle(); // eslint-disable-line no-new
+initClusterHealth();
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 76db578f6f9..342c054471d 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,5 +1,5 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
-import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
+import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
import { mountIssuablesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 2f6f9bb16e1..02a0a50f984 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,6 +1,6 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
+import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 2a2cc5faebe..914e2831185 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,7 +1,5 @@
import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
-document.addEventListener('DOMContentLoaded', () => {
- initMilestonesShow();
- initDeleteMilestoneModal();
-});
+initMilestonesShow();
+initDeleteMilestoneModal();
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index a0ff98645fb..c58be202043 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
import fetchGroupPathAvailability from './fetch_group_path_availability';
@@ -12,7 +12,6 @@ const parentIdSelector = 'group_parent_id';
const successMessageSelector = '.validation-success';
const pendingMessageSelector = '.validation-pending';
const unavailableMessageSelector = '.validation-error';
-const suggestionsMessageSelector = '.gl-path-suggestions';
const inputGroupSelector = '.input-group';
export default class GroupPathValidator extends InputValidator {
@@ -57,21 +56,19 @@ export default class GroupPathValidator extends InputValidator {
);
if (data.exists) {
- GroupPathValidator.showSuggestions(inputDomElement, data.suggests);
+ const [suggestedSlug] = data.suggests;
+ const targetDomElement = document.querySelector('.js-autofill-group-path');
+ targetDomElement.value = suggestedSlug;
}
})
- .catch(() => flash(__('An error occurred while validating group path')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while validating group path'),
+ }),
+ );
}
}
- static showSuggestions(inputDomElement, suggestions) {
- const messageElement = inputDomElement.parentElement.parentElement.querySelector(
- suggestionsMessageSelector,
- );
- const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none';
- messageElement.textContent = textSuggestions;
- }
-
static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) {
const messageElement = inputDomElement
.closest(inputGroupSelector)
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
index 16f68b94c9a..34f9fe778ea 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
@@ -98,17 +98,17 @@ Once deleted, it cannot be undone or recovered.`),
});
if (error.response && error.response.status === 404) {
- Flash(
- sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
+ createFlash({
+ message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
milestoneTitle: this.milestoneTitle,
}),
- );
+ });
} else {
- Flash(
- sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), {
+ createFlash({
+ message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), {
milestoneTitle: this.milestoneTitle,
}),
- );
+ });
}
throw error;
});
diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js
index 51ba6c7a01e..6aa0f260cc0 100644
--- a/app/assets/javascripts/pages/profiles/notifications/show/index.js
+++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js
@@ -1,5 +1,3 @@
import initNotificationsDropdown from '~/notifications';
-document.addEventListener('DOMContentLoaded', () => {
- initNotificationsDropdown();
-});
+initNotificationsDropdown();
diff --git a/app/assets/javascripts/pages/projects/artifacts/browse/index.js b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
index 58ba6a500a3..60680ec7d1d 100644
--- a/app/assets/javascripts/pages/projects/artifacts/browse/index.js
+++ b/app/assets/javascripts/pages/projects/artifacts/browse/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BuildArtifacts from '~/build_artifacts';
-document.addEventListener('DOMContentLoaded', () => {
- new ShortcutsNavigation(); // eslint-disable-line no-new
- new BuildArtifacts(); // eslint-disable-line no-new
-});
+new ShortcutsNavigation(); // eslint-disable-line no-new
+new BuildArtifacts(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/artifacts/file/index.js b/app/assets/javascripts/pages/projects/artifacts/file/index.js
index eb5ecc27c43..057ef157374 100644
--- a/app/assets/javascripts/pages/projects/artifacts/file/index.js
+++ b/app/assets/javascripts/pages/projects/artifacts/file/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import BlobViewer from '~/blob/viewer/index';
-document.addEventListener('DOMContentLoaded', () => {
- new ShortcutsNavigation(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
-});
+new ShortcutsNavigation(); // eslint-disable-line no-new
+new BlobViewer(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/clusters/destroy/index.js b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/projects/clusters/destroy/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/destroy/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/clusters/edit/index.js b/app/assets/javascripts/pages/projects/clusters/edit/index.js
index 8001d2dd1da..487e7a14a16 100644
--- a/app/assets/javascripts/pages/projects/clusters/edit/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/edit/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
-});
+new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/clusters/index.js b/app/assets/javascripts/pages/projects/clusters/index.js
index 4d04c37caa7..f398b1cee82 100644
--- a/app/assets/javascripts/pages/projects/clusters/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index.js
@@ -1,5 +1,3 @@
import initCreateCluster from '~/create_cluster/init_create_cluster';
-document.addEventListener('DOMContentLoaded', () => {
- initCreateCluster(document, gon);
-});
+initCreateCluster(document, gon);
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
index 1d019285e23..71ab5a0b19c 100644
--- a/app/assets/javascripts/pages/projects/clusters/show/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -3,9 +3,7 @@ import initIntegrationForm from '~/clusters/forms/show';
import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
import initClusterHealth from './cluster_health';
-document.addEventListener('DOMContentLoaded', () => {
- new ClustersBundle(); // eslint-disable-line no-new
- initGkeNamespace();
- initClusterHealth();
- initIntegrationForm();
-});
+new ClustersBundle(); // eslint-disable-line no-new
+initGkeNamespace();
+initClusterHealth();
+initIntegrationForm();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index d75c3cc6b8b..e3b30560fef 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -3,7 +3,7 @@ import $ from 'jquery';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
-import flash from '~/flash';
+import createFlash from '~/flash';
import initChangesDropdown from '~/init_changes_dropdown';
import initNotes from '~/init_notes';
import axios from '~/lib/utils/axios_utils';
@@ -39,7 +39,7 @@ if (filesContainer.length) {
new Diff();
})
.catch(() => {
- flash({ message: __('An error occurred while retrieving diff files') });
+ createFlash({ message: __('An error occurred while retrieving diff files') });
});
} else {
new Diff();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 75c3b6d564c..795ae713c08 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -39,6 +39,14 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({
feedback: null,
});
+function sortNamespaces(namespaces) {
+ if (!namespaces || !namespaces?.length) {
+ return namespaces;
+ }
+
+ return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name));
+}
+
export default {
components: {
GlForm,
@@ -206,7 +214,7 @@ export default {
methods: {
async fetchNamespaces() {
const { data } = await axios.get(this.endpoint);
- this.namespaces = data.namespaces;
+ this.namespaces = sortNamespaces(data.namespaces);
},
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
@@ -301,11 +309,11 @@ export default {
:state="form.fields.namespace.state"
required
>
- <template slot="first">
+ <template #first>
<option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
</template>
<option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
- {{ namespace.name }}
+ {{ namespace.full_name }}
</option>
</gl-form-select>
</gl-form-input-group>
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
index 88f4bba5e2a..d41488acf46 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue
@@ -101,7 +101,7 @@ export default {
v-if="isGroupPendingRemoval"
variant="warning"
class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1"
- >{{ __('pending removal') }}</gl-badge
+ >{{ __('pending deletion') }}</gl-badge
>
<user-access-role-badge v-if="group.permission" class="gl-mt-3">
{{ group.permission }}
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 1eab3becbc3..8ec6e5e66b3 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,7 +1,9 @@
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
+import initTerraformNotification from '../../projects/terraform_notification';
import { initSidebarTracking } from '../shared/nav/sidebar_tracking';
import Project from './project';
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initSidebarTracking();
+initTerraformNotification();
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index aaa9bb906b2..e708cd32fff 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -85,28 +85,29 @@ export default {
:action-cancel="$options.cancelProps"
@primary="onSubmit"
>
- <div slot="modal-title" class="modal-title-with-label">
- <gl-sprintf
- :message="
- s__(
- 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}',
- )
- "
- >
- <template #labelTitle>
- <span
- class="label color-label"
- :style="`background-color: ${labelColor}; color: ${labelTextColor};`"
- >
- {{ labelTitle }}
- </span>
- </template>
- <template #span="{ content }"
- ><span>{{ content }}</span></template
+ <template #modal-title>
+ <div class="modal-title-with-label">
+ <gl-sprintf
+ :message="
+ s__(
+ 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}',
+ )
+ "
>
- </gl-sprintf>
- </div>
-
+ <template #labelTitle>
+ <span
+ class="label color-label"
+ :style="`background-color: ${labelColor}; color: ${labelTextColor};`"
+ >
+ {{ labelTitle }}
+ </span>
+ </template>
+ <template #span="{ content }"
+ ><span>{{ content }}</span></template
+ >
+ </gl-sprintf>
+ </div>
+ </template>
{{ text }}
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
index 05019915fc9..545a39f4cf1 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
@@ -1,7 +1,5 @@
import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- initSidebarBundle();
- initMergeConflicts();
-});
+initSidebarBundle();
+initMergeConflicts();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
index 8d152ec4ba6..d61209f904d 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
@@ -15,7 +15,7 @@ const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
.then(({ data }) => {
$loadingIndicator.hide();
$commitList.html(data);
- localTimeAgo($('.js-timeago', $commitList));
+ localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago'));
});
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
index 68ab7021cf3..e5f97530c02 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
@@ -37,7 +37,11 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
callback(data);
}
})
- .catch(() => flash(__('Error fetching refs')));
+ .catch(() =>
+ createFlash({
+ message: __('Error fetching refs'),
+ }),
+ );
},
selectable: true,
filterable: true,
diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/pages/projects/new/components/app.vue
index 60a4fbc3e6b..6e9efc50be8 100644
--- a/app/assets/javascripts/pages/projects/new/components/app.vue
+++ b/app/assets/javascripts/pages/projects/new/components/app.vue
@@ -4,12 +4,10 @@ import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-cr
import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg';
import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg';
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import { experiment } from '~/experimentation/utils';
import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
-const NEW_REPO_EXPERIMENT = 'new_repo';
const CI_CD_PANEL = 'cicd_for_external_repo';
const PANELS = [
{
@@ -79,28 +77,8 @@ export default {
},
computed: {
- decoratedPanels() {
- const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, {
- use: () => ({
- blank: s__('ProjectsNew|Create blank project'),
- import: s__('ProjectsNew|Import project'),
- }),
- try: () => ({
- blank: s__('ProjectsNew|Create blank project/repository'),
- import: s__('ProjectsNew|Import project/repository'),
- }),
- });
-
- return PANELS.map(({ key, title, ...el }) => ({
- ...el,
- title: PANEL_TITLES[key] ?? title,
- }));
- },
-
availablePanels() {
- return this.isCiCdAvailable
- ? this.decoratedPanels
- : this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL);
+ return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL);
},
},
@@ -112,7 +90,6 @@ export default {
}
},
},
- EXPERIMENT: NEW_REPO_EXPERIMENT,
};
</script>
@@ -122,7 +99,6 @@ export default {
:panels="availablePanels"
:jump-to-last-persisted-panel="hasErrors"
:title="s__('ProjectsNew|Create new project')"
- :experiment="$options.EXPERIMENT"
persistence-key="new_project_last_active_tab"
@panel-change="resetProjectErrors"
>
diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
index 1afb900ed88..ee06f247ddc 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
@@ -1,3 +1,11 @@
-import initPackageDetail from '~/packages/details/';
-
-initPackageDetail();
+(async function initPackage() {
+ let app;
+ if (document.getElementById('js-vue-packages-detail-new')) {
+ app = await import(
+ /* webpackChunkName: 'new_package_app' */ `~/packages_and_registries/package_registry/pages/details.js`
+ );
+ } else {
+ app = await import('~/packages/details/');
+ }
+ app.default();
+})();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
index d65be6bc69e..6dd21380bec 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js
@@ -1,3 +1,3 @@
import initForm from '../shared/init_form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
index d65be6bc69e..6dd21380bec 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js
@@ -1,3 +1,3 @@
import initForm from '../shared/init_form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
index d65be6bc69e..6dd21380bec 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js
@@ -1,3 +1,3 @@
import initForm from '../shared/init_form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
index d65be6bc69e..6dd21380bec 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js
@@ -1,3 +1,3 @@
import initForm from '../shared/init_form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 3b24c2c128b..9e93f709937 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import initClonePanel from '~/clone_panel';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { serializeForm } from '~/lib/utils/forms';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -78,7 +78,11 @@ export default class Project {
},
})
.then(({ data }) => callback(data))
- .catch(() => flash(__('An error occurred while getting projects')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while getting projects'),
+ }),
+ );
},
selectable: true,
filterable: true,
diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js
index 101cb8356b2..8bba3d7af54 100644
--- a/app/assets/javascripts/pages/projects/security/configuration/index.js
+++ b/app/assets/javascripts/pages/projects/security/configuration/index.js
@@ -1,3 +1,3 @@
-import { initStaticSecurityConfiguration } from '~/security_configuration';
+import { initCESecurityConfiguration } from '~/security_configuration';
-initStaticSecurityConfiguration(document.querySelector('#js-security-configuration-static'));
+initCESecurityConfiguration(document.querySelector('#js-security-configuration-static'));
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index db7b3bad6ed..e88dbf20e1b 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -8,6 +8,7 @@ import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deploy
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
+import { initTokenAccess } from '~/token_access';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => {
initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
+ initTokenAccess();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 11e6b4577e0..6fcaa3ab04b 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -104,6 +104,11 @@ export default {
required: false,
default: '',
},
+ issuesHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
lfsHelpPath: {
type: String,
required: false,
@@ -438,8 +443,13 @@ export default {
>
<project-setting-row
ref="issues-settings"
+ :help-path="issuesHelpPath"
:label="$options.i18n.issuesLabel"
- :help-text="s__('ProjectSettings|Lightweight issue tracking system.')"
+ :help-text="
+ s__(
+ 'ProjectSettings|Flexible tool to collaboratively develop ideas and plan work in this project.',
+ )
+ "
>
<project-feature-setting
v-model="issuesAccessLevel"
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index 4104025aa59..ae605edeaf0 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -2,8 +2,6 @@ import NoEmojiValidator from '~/emoji/no_emoji_validator';
import LengthValidator from '~/pages/sessions/new/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
-document.addEventListener('DOMContentLoaded', () => {
- new UsernameValidator(); // eslint-disable-line no-new
- new LengthValidator(); // eslint-disable-line no-new
- new NoEmojiValidator(); // eslint-disable-line no-new
-});
+new UsernameValidator(); // eslint-disable-line no-new
+new LengthValidator(); // eslint-disable-line no-new
+new NoEmojiValidator(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index d39f56cfd03..465aed88c01 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import initVueAlerts from '~/vue_alerts';
import NoEmojiValidator from '../../../emoji/no_emoji_validator';
import LengthValidator from './length_validator';
import OAuthRememberMe from './oauth_remember_me';
@@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => {
// Save the URL fragment from the current window location. This will be present if the user was
// redirected to sign-in after attempting to access a protected URL that included a fragment.
preserveUrlFragment(window.location.hash);
+ initVueAlerts();
});
diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index 338fe1b66f2..7ea744a68a6 100644
--- a/app/assets/javascripts/pages/sessions/new/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
@@ -50,7 +50,11 @@ export default class UsernameValidator extends InputValidator {
usernameTaken ? unavailableMessageSelector : successMessageSelector,
);
})
- .catch(() => flash(__('An error occurred while validating username')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while validating username'),
+ }),
+ );
}
}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 26f6d1d683a..e883fecb170 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -212,13 +212,20 @@ export default {
.then(({ data }) => data.body);
},
- handleFormSubmit() {
+ async handleFormSubmit(e) {
+ e.preventDefault();
+
if (this.useContentEditor) {
this.content = this.contentEditor.getSerializedContent();
this.trackFormSubmit();
}
+ // Wait until form field values are refreshed
+ await this.$nextTick();
+
+ e.target.submit();
+
this.isDirty = false;
},
@@ -257,6 +264,7 @@ export default {
this.contentEditor ||
createContentEditor({
renderMarkdown: (markdown) => this.getContentHTML(markdown),
+ uploadsPath: this.pageInfo.uploadsPath,
tiptapOptions: {
onUpdate: () => this.handleContentChange(),
},
@@ -454,7 +462,7 @@ export default {
</markdown-field>
<div v-if="isContentEditorActive">
- <gl-alert class="gl-mb-6" variant="tip" :dismissable="false">
+ <gl-alert class="gl-mb-6" variant="tip" :dismissible="false">
<gl-sprintf :message="$options.i18n.contentEditor.feedbackTip">
<template
#link="// eslint-disable-next-line vue/no-template-shadow
@@ -468,7 +476,11 @@ export default {
>
</gl-sprintf>
</gl-alert>
- <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" />
+ <gl-loading-icon
+ v-if="isContentEditorLoading"
+ size="sm"
+ class="bordered-box gl-w-full gl-py-6"
+ />
<content-editor v-else :content-editor="contentEditor" />
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div>
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
index c04cd0b3fa4..42aefe81325 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -27,8 +27,10 @@ const createModalVueApp = () => {
// eslint-disable-next-line no-new
new Vue({
el: deleteWikiModalWrapperEl,
- data: {
- deleteWikiUrl: '',
+ data() {
+ return {
+ deleteWikiUrl: '',
+ };
},
render(createElement) {
return createElement(deleteWikiModal, {
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 03dba699461..0fab4678bc3 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -2,7 +2,7 @@ import { select } from 'd3-selection';
import dateFormat from 'dateformat';
import $ from 'jquery';
import { last } from 'lodash';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { n__, s__, __ } from '~/locale';
@@ -295,7 +295,11 @@ export default class ActivityCalendar {
responseType: 'text',
})
.then(({ data }) => $(this.activitiesContainer).html(data))
- .catch(() => flash(__('An error occurred while retrieving calendar activity')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while retrieving calendar activity'),
+ }),
+ );
} else {
this.currentSelectedDate = '';
$(this.activitiesContainer).html('');
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index f9d70845560..90eafa85886 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -166,7 +166,7 @@ export default class UserTabs {
const tabSelector = `div#${action}`;
this.$parentEl.find(tabSelector).html(data.html);
this.loaded[action] = true;
- localTimeAgo($('.js-timeago', tabSelector));
+ localTimeAgo(document.querySelectorAll(`${tabSelector} .js-timeago`));
this.toggleLoading(false);
})
@@ -209,7 +209,7 @@ export default class UserTabs {
container,
url: $(`${container} .overview-content-list`).data('href'),
...options,
- postRenderCallback: () => localTimeAgo($('.js-timeago', container)),
+ postRenderCallback: () => localTimeAgo(document.querySelectorAll(`${container} .js-timeago`)),
});
}
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 1db80057d0c..b9a9ef215af 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -83,7 +83,9 @@ export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ra
// Marks
export const REPO_BLOB_LOAD_VIEWER_START = 'blobviewer-load-viewer-start';
+export const REPO_BLOB_SWITCH_TO_VIEWER_START = 'blobviewer-switch-to-viewerr-start';
export const REPO_BLOB_LOAD_VIEWER_FINISH = 'blobviewer-load-viewer-finish';
// Measures
-export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the content';
+export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the viewer';
+export const REPO_BLOB_SWITCH_VIEWER = 'Repository File Viewer: switching the viewer';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 04efc459a21..f163a7c3a8e 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -27,9 +27,7 @@ export default {
title: {
type: String,
required: false,
- default() {
- return this.metric;
- },
+ default: null,
},
header: {
type: String,
@@ -101,6 +99,9 @@ export default {
return '';
},
+ actualTitle() {
+ return this.title ?? this.metric;
+ },
},
methods: {
toggleBacktrace(toggledIndex) {
@@ -214,7 +215,7 @@ export default {
<div></div>
</template>
</gl-modal>
- {{ title }}
+ {{ actualTitle }}
<request-warning :html-id="htmlId" :warnings="warnings" />
</div>
</template>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index e845c8b9df4..bc83844b8b9 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
@@ -62,7 +62,11 @@ export default class PersistentUserCallout {
}
})
.catch(() => {
- Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
+ createFlash({
+ message: __(
+ 'An error occurred while dismissing the alert. Refresh the page and try again.',
+ ),
+ });
});
}
@@ -79,11 +83,11 @@ export default class PersistentUserCallout {
window.location.assign(href);
})
.catch(() => {
- Flash(
- __(
+ createFlash({
+ message: __(
'An error occurred while acknowledging the notification. Refresh the page and try again.',
),
- );
+ });
});
}
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index f6e88738002..f1fe8cf10fd 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -103,6 +103,7 @@ export default {
v-model="targetBranch"
class="gl-font-monospace!"
required
+ data-qa-selector="target_branch_field"
/>
<gl-form-checkbox
v-if="!isCurrentBranchTarget"
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index 455990f2791..853e839a7ab 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -2,14 +2,14 @@
import { GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { s__ } from '~/locale';
-import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
i18n: {
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
},
components: {
- EditorLite,
+ SourceEditor,
GlIcon,
},
inject: ['ciConfigPath'],
@@ -41,7 +41,7 @@ export default {
{{ $options.i18n.viewOnlyMessage }}
</div>
<div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
+ <source-editor
ref="editor"
:value="mergedYaml"
:file-name="ciConfigPath"
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
new file mode 100644
index 00000000000..b4e9ab81d38
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants';
+
+export default {
+ i18n: {
+ browseTemplates: __('Browse templates'),
+ },
+ TEMPLATE_REPOSITORY_URL,
+ components: {
+ GlButton,
+ },
+ mixins: [Tracking.mixin()],
+ methods: {
+ trackTemplateBrowsing() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+
+ this.track(actions.browse_templates, { label });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-bg-gray-10 gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <gl-button
+ :href="$options.TEMPLATE_REPOSITORY_URL"
+ size="small"
+ icon="external-link"
+ target="_blank"
+ @click="trackTemplateBrowsing"
+ >
+ {{ $options.i18n.browseTemplates }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index d373f74a5c4..77ede396496 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -1,13 +1,13 @@
<script>
import { EDITOR_READY_EVENT } from '~/editor/constants';
-import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext';
-import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
export default {
components: {
- EditorLite,
+ SourceEditor,
},
mixins: [glFeatureFlagMixin()],
inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'],
@@ -43,8 +43,8 @@ export default {
};
</script>
<template>
- <div class="gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
+ <div class="gl-border-solid gl-border-gray-100 gl-border-1 gl-border-t-none!">
+ <source-editor
ref="editor"
:file-name="ciConfigPath"
v-bind="$attrs"
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 05b87abecd5..ee6d4ff7c4d 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -158,6 +158,12 @@ export default {
const updatedPath = setUrlParams({ branch_name: newBranch });
historyPushState(updatedPath);
+ this.$emit('updateCommitSha', { newBranch });
+
+ // refetching the content will cause a lot of components to re-render,
+ // including the text editor which uses the commit sha to register the CI schema
+ // so we need to make sure the commit sha is updated first
+ await this.$nextTick();
this.$emit('refetchContent');
},
async setSearchTerm(newSearchTerm) {
@@ -205,6 +211,7 @@ export default {
:header-text="$options.i18n.dropdownHeader"
:text="currentBranch"
icon="branch"
+ data-qa-selector="branch_selector_button"
>
<gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" />
<gl-dropdown-section-header>
@@ -222,6 +229,7 @@ export default {
:key="branch"
:is-checked="currentBranch === branch"
:is-check-item="true"
+ data-qa-selector="menu_branch_button"
@click="selectBranch(branch)"
>
{{ branch }}
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 368a026bdaa..6af3361e7e6 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -66,6 +66,7 @@ export default {
},
data() {
return {
+ commitSha: '',
hasError: false,
};
},
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index d1534655a00..8bffd893473 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -87,7 +87,7 @@ export default {
<template>
<div>
<template v-if="isLoading">
- <gl-loading-icon inline />
+ <gl-loading-icon size="sm" inline />
{{ $options.i18n.loading }}
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index c3dcc00af6e..e463fcf379d 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -16,6 +16,7 @@ import {
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
+import CiEditorHeader from './editor/ci_editor_header.vue';
import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
import EditorTab from './ui/editor_tab.vue';
@@ -49,6 +50,7 @@ export default {
},
components: {
CiConfigMergedPreview,
+ CiEditorHeader,
CiLint,
EditorTab,
GlAlert,
@@ -107,6 +109,7 @@ export default {
data-testid="editor-tab"
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
+ <ci-editor-header />
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<editor-tab
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 1467abd7289..d05b06d16db 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -33,3 +33,13 @@ export const BRANCH_PAGINATION_LIMIT = 20;
export const BRANCH_SEARCH_DEBOUNCE = '500';
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
+
+export const pipelineEditorTrackingOptions = {
+ label: 'pipeline_editor',
+ actions: {
+ browse_templates: 'browse_templates',
+ },
+};
+
+export const TEMPLATE_REPOSITORY_URL =
+ 'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql
new file mode 100644
index 00000000000..dce17cad808
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateCommitSha($commitSha: String) {
+ updateCommitSha(commitSha: $commitSha) @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
index 9f1b5b13088..5500244b430 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql
@@ -1,5 +1,11 @@
-query getBlobContent($projectPath: ID!, $path: String, $ref: String!) {
- blobContent(projectPath: $projectPath, path: $path, ref: $ref) @client {
- rawData
+query getBlobContent($projectPath: ID!, $path: String!, $ref: String) {
+ project(fullPath: $projectPath) {
+ repository {
+ blobs(paths: [$path], ref: $ref) {
+ nodes {
+ rawBlob
+ }
+ }
+ }
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
index 30c18a96536..df7de6a1f54 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
@@ -1,7 +1,7 @@
#import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql"
-query getCiConfigData($projectPath: ID!, $content: String!) {
- ciConfig(projectPath: $projectPath, content: $content) {
+query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) {
+ ciConfig(projectPath: $projectPath, sha: $sha, content: $content) {
errors
mergedYaml
status
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
new file mode 100644
index 00000000000..219c23bb22b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
@@ -0,0 +1,12 @@
+query getLatestCommitSha($projectPath: ID!, $ref: String) {
+ project(fullPath: $projectPath) {
+ pipelines(ref: $ref) {
+ nodes {
+ id
+ sha
+ path
+ commitPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 8cead7f3315..2bec2006e95 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -1,20 +1,10 @@
import produce from 'immer';
-import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
+import getCommitShaQuery from './queries/client/commit_sha.graphql';
import getCurrentBranchQuery from './queries/client/current_branch.graphql';
import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql';
export const resolvers = {
- Query: {
- blobContent(_, { projectPath, path, ref }) {
- return {
- __typename: 'BlobContent',
- rawData: Api.getRawFile(projectPath, path, { ref }).then(({ data }) => {
- return data;
- }),
- };
- },
- },
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
@@ -42,7 +32,15 @@ export const resolvers = {
__typename: 'CiLintContent',
}));
},
- updateCurrentBranch: (_, { currentBranch = undefined }, { cache }) => {
+ updateCommitSha: (_, { commitSha }, { cache }) => {
+ cache.writeQuery({
+ query: getCommitShaQuery,
+ data: produce(cache.readQuery({ query: getCommitShaQuery }), (draftData) => {
+ draftData.commitSha = commitSha;
+ }),
+ });
+ },
+ updateCurrentBranch: (_, { currentBranch }, { cache }) => {
cache.writeQuery({
query: getCurrentBranchQuery,
data: produce(cache.readQuery({ query: getCurrentBranchQuery }), (draftData) => {
@@ -50,7 +48,7 @@ export const resolvers = {
}),
});
},
- updateLastCommitBranch: (_, { lastCommitBranch = undefined }, { cache }) => {
+ updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => {
cache.writeQuery({
query: getLastCommitBranchQuery,
data: produce(cache.readQuery({ query: getLastCommitBranchQuery }), (draftData) => {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index c24e6523352..0e8a6805a59 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
-import httpStatusCodes from '~/lib/utils/http_status';
+import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -16,12 +16,15 @@ import {
LOAD_FAILURE_UNKNOWN,
STARTER_TEMPLATE_NAME,
} from './constants';
+import updateCommitShaMutation from './graphql/mutations/update_commit_sha.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
import getAppStatus from './graphql/queries/client/app_status.graphql';
+import getCommitSha from './graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import getTemplate from './graphql/queries/get_starter_template.query.graphql';
+import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
export default {
@@ -42,6 +45,7 @@ export default {
},
data() {
return {
+ starterTemplateName: STARTER_TEMPLATE_NAME,
ciConfigData: {},
failureType: null,
failureReasons: [],
@@ -76,22 +80,40 @@ export default {
};
},
update(data) {
- return data?.blobContent?.rawData;
+ return data?.project?.repository?.blobs?.nodes[0]?.rawBlob;
},
result({ data }) {
- const fileContent = data?.blobContent?.rawData ?? '';
+ const nodes = data?.project?.repository?.blobs?.nodes;
+ if (!nodes) {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ } else {
+ const rawBlob = nodes[0]?.rawBlob;
+ const fileContent = rawBlob ?? '';
- this.lastCommittedContent = fileContent;
- this.currentCiFileContent = fileContent;
+ this.lastCommittedContent = fileContent;
+ this.currentCiFileContent = fileContent;
- // make sure to reset the start screen flag during a refetch
- // e.g. when switching branches
- if (fileContent.length) {
- this.showStartScreen = false;
+ // If rawBlob is defined and returns a string, it means that there is
+ // a CI config file with empty content. If `rawBlob` is not defined
+ // at all, it means there was no file found.
+ const hasCIFile = rawBlob === '' || fileContent.length > 0;
+
+ if (!fileContent.length) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
+ }
+
+ if (!hasCIFile) {
+ this.showStartScreen = true;
+ } else if (fileContent.length) {
+ // If the file content is > 0, then we make sure to reset the
+ // start screen flag during a refetch
+ // e.g. when switching branches
+ this.showStartScreen = false;
+ }
}
},
- error(error) {
- this.handleBlobContentError(error);
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
watchLoading(isLoading) {
if (isLoading) {
@@ -107,6 +129,7 @@ export default {
variables() {
return {
projectPath: this.projectFullPath,
+ sha: this.commitSha,
content: this.currentCiFileContent,
};
},
@@ -132,6 +155,9 @@ export default {
appStatus: {
query: getAppStatus,
},
+ commitSha: {
+ query: getCommitSha,
+ },
currentBranch: {
query: getCurrentBranch,
},
@@ -143,7 +169,7 @@ export default {
variables() {
return {
projectPath: this.projectFullPath,
- templateName: STARTER_TEMPLATE_NAME,
+ templateName: this.starterTemplateName,
};
},
skip({ isNewCiConfigFile }) {
@@ -186,23 +212,10 @@ export default {
}
},
},
+ mounted() {
+ this.loadTemplateFromURL();
+ },
methods: {
- handleBlobContentError(error = {}) {
- const { networkError } = error;
-
- const { response } = networkError;
- // 404 for missing CI file
- // 400 for blank projects with no repository
- if (
- response?.status === httpStatusCodes.NOT_FOUND ||
- response?.status === httpStatusCodes.BAD_REQUEST
- ) {
- this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
- this.showStartScreen = true;
- } else {
- this.reportFailure(LOAD_FAILURE_UNKNOWN);
- }
- },
hideFailure() {
this.showFailure = false;
},
@@ -244,6 +257,38 @@ export default {
updateCiConfig(ciFileContent) {
this.currentCiFileContent = ciFileContent;
},
+ async updateCommitSha({ newBranch }) {
+ let fetchResults;
+
+ try {
+ fetchResults = await this.$apollo.query({
+ query: getLatestCommitShaQuery,
+ variables: {
+ projectPath: this.projectFullPath,
+ ref: newBranch,
+ },
+ });
+ } catch {
+ this.showFetchError();
+ return;
+ }
+
+ if (fetchResults.errors?.length > 0) {
+ this.showFetchError();
+ return;
+ }
+
+ const pipelineNodes = fetchResults?.data?.project?.pipelines?.nodes ?? [];
+ if (pipelineNodes.length === 0) {
+ return;
+ }
+
+ const commitSha = pipelineNodes[0].sha;
+ this.$apollo.mutate({
+ mutation: updateCommitShaMutation,
+ variables: { commitSha },
+ });
+ },
updateOnCommit({ type }) {
this.reportSuccess(type);
@@ -257,6 +302,14 @@ export default {
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
},
+ loadTemplateFromURL() {
+ const templateName = queryToObject(window.location.search)?.template;
+
+ if (templateName) {
+ this.starterTemplateName = templateName;
+ this.setNewEmptyCiConfigFile();
+ }
+ },
},
};
</script>
@@ -288,6 +341,7 @@ export default {
@showError="showErrorAlert"
@refetchContent="refetchContent"
@updateCiConfig="updateCiConfig"
+ @updateCommitSha="updateCommitSha"
/>
<confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
</div>
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index 91a064a0fb8..a6c9f3cb746 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,6 +1,8 @@
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
-export const DEBOUNCE_REFS_SEARCH_MS = 250;
+export const DEBOUNCE_REFS_SEARCH_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 71ec81b8969..ea45b5e3ec7 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -101,9 +101,6 @@ export default {
showJobLinks() {
return !this.isStageView && this.showLinks;
},
- shouldShowStageName() {
- return !this.isStageView;
- },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -165,8 +162,10 @@ export default {
<div class="js-pipeline-graph">
<div
ref="mainPipelineContainer"
- class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100"
- :class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }"
+ class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
+ :class="{
+ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto gl-border-t-solid gl-border-t-1 gl-border-gray-100': !isLinkedPipeline,
+ }"
>
<linked-graph-wrapper>
<template #upstream>
@@ -202,11 +201,12 @@ export default {
:groups="column.groups"
:action="column.status.action"
:highlighted-jobs="highlightedJobs"
- :show-stage-name="shouldShowStageName"
+ :is-stage-view="isStageView"
:job-hovered="hoveredJobName"
:source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
+ :user-permissions="pipeline.userPermissions"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@jobHover="setJob"
@updateMeasurements="getMeasurements"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index fb45738f8d1..a948a57c144 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -105,7 +105,7 @@ export default {
return this.pipeline;
}
- return unwrapPipelineData(this.pipelineProjectPath, data);
+ return unwrapPipelineData(this.pipelineProjectPath, JSON.parse(JSON.stringify(data)));
},
error(err) {
this.reportFailure({ type: LOAD_FAILURE, skipSentry: true });
@@ -114,7 +114,7 @@ export default {
this.$options.name,
`| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`,
{
- projectPath: this.projectPath,
+ projectPath: this.pipelineProjectPath,
pipelineIid: this.pipelineIid,
pipelineStages: this.pipeline?.stages?.length || 0,
nbOfDownstreams: this.pipeline?.downstream?.length || 0,
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index b3c5af5418f..dd8a354511a 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -161,7 +161,7 @@ export default {
:size="24"
css-classes="gl-top-0 gl-pr-2"
/>
- <div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
+ <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div>
<div class="gl-display-flex gl-flex-direction-column gl-w-13">
<span class="gl-text-truncate" data-testid="downstream-title">
{{ downstreamTitle }}
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index 45113ecff41..52ee40bd982 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -118,7 +118,7 @@ export default {
return this.currentPipeline;
}
- return unwrapPipelineData(projectPath, data);
+ return unwrapPipelineData(projectPath, JSON.parse(JSON.stringify(data)));
},
result() {
this.loadingPipelineId = null;
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 81d59f1ef65..d34ae8036ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -40,6 +40,11 @@ export default {
required: false,
default: () => [],
},
+ isStageView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
jobHovered: {
type: String,
required: false,
@@ -50,16 +55,15 @@ export default {
required: false,
default: () => ({}),
},
- showStageName: {
- type: Boolean,
- required: false,
- default: false,
- },
sourceJobHovered: {
type: String,
required: false,
default: '',
},
+ userPermissions: {
+ type: Object,
+ required: true,
+ },
},
titleClasses: [
'gl-font-weight-bold',
@@ -69,20 +73,11 @@ export default {
'gl-pl-3',
],
computed: {
- /*
- currentGroups and filteredGroups are part of
- a test to hunt down a bug
- (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142).
-
- They should be removed when the bug is rectified.
- */
- currentGroups() {
- return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups;
+ canUpdatePipeline() {
+ return this.userPermissions.updatePipeline;
},
- filteredGroups() {
- return this.groups.map((group) => {
- return { ...group, jobs: group.jobs.filter(Boolean) };
- });
+ columnSpacingClass() {
+ return this.isStageView ? 'gl-px-6' : 'gl-px-9';
},
formattedTitle() {
return capitalize(escape(this.name));
@@ -90,6 +85,9 @@ export default {
hasAction() {
return !isEmpty(this.action);
},
+ showStageName() {
+ return !this.isStageView;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('stage_column_component', `error: ${err}, info: ${info}`);
@@ -123,7 +121,7 @@ export default {
};
</script>
<template>
- <main-graph-wrapper class="gl-px-6" data-testid="stage-column">
+ <main-graph-wrapper :class="columnSpacingClass" data-testid="stage-column">
<template #stages>
<div
data-testid="stage-column-title"
@@ -132,7 +130,7 @@ export default {
>
<div>{{ formattedTitle }}</div>
<action-component
- v-if="hasAction"
+ v-if="hasAction && canUpdatePipeline"
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
@@ -143,7 +141,7 @@ export default {
</template>
<template #jobs>
<div
- v-for="group in currentGroups"
+ v-for="group in groups"
:id="groupId(group)"
:key="getGroupId(group)"
data-testid="stage-column-group"
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
index 7c62acbe8de..83f2466f0bf 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
@@ -75,11 +75,11 @@ export const generateLinksData = ({ links }, containerID, modifier = '') => {
// until we can safely draw the bezier to look nice.
// The adjustment number here is a magic number to make things
// look nice and should change if the padding changes. This goes well
- // with gl-px-6. gl-px-8 is more like 100.
- const straightLineDestinationX = targetNodeX - 60;
+ // with gl-px-9 which we translate with 100px here.
+ const straightLineDestinationX = targetNodeX - 100;
const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2;
- if (straightLineDestinationX > 0) {
+ if (straightLineDestinationX > firstPointCoordinateX) {
path.lineTo(straightLineDestinationX, sourceNodeY);
}
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index d19215e7895..efad43ddd4f 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -99,7 +99,7 @@ export default {
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
@click.stop="onClickAction"
>
- <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
+ <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" />
<gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index f1d9ced807b..b36c9c0d049 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,4 +1,4 @@
-import { isEqual, memoize, uniqWith } from 'lodash';
+import { memoize } from 'lodash';
import { createSankey } from './dag/drawing_utils';
/*
@@ -113,11 +113,24 @@ export const filterByAncestors = (links, nodeDict) =>
return !allAncestors.includes(source);
});
+/*
+ A peformant alternative to lodash's isEqual. Because findIndex always finds
+ the first instance of a match, if the found index is not the first, we know
+ it is in fact a duplicate.
+*/
+const deduplicate = (item, itemIndex, arr) => {
+ const foundIdx = arr.findIndex((test) => {
+ return test.source === item.source && test.target === item.target;
+ });
+
+ return foundIdx === itemIndex;
+};
+
export const parseData = (nodes) => {
const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict);
const filteredLinks = filterByAncestors(allLinks, nodeDict);
- const links = uniqWith(filteredLinks, isEqual);
+ const links = filteredLinks.filter(deduplicate);
return { nodes, links };
};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 01baf0a42d5..836333c8bde 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -14,7 +14,7 @@ export default {
type: Number,
required: true,
},
- isHighlighted: {
+ isHovered: {
type: Boolean,
required: false,
default: false,
@@ -42,7 +42,7 @@ export default {
jobPillClasses() {
return [
{ 'gl-opacity-3': this.isFadedOut },
- this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400',
+ { 'gl-bg-gray-50 gl-inset-border-1-gray-200': this.isHovered },
];
},
},
@@ -57,15 +57,17 @@ export default {
};
</script>
<template>
- <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
- <div
- :id="id"
- class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
- :class="jobPillClasses"
- @mouseover="onMouseEnter"
- @mouseleave="onMouseLeave"
- >
- {{ jobName }}
- </div>
- </tooltip-on-truncate>
+ <div class="gl-w-full">
+ <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
+ <div
+ :id="id"
+ class="gl-bg-white gl-inset-border-1-gray-100 gl-text-center gl-text-truncate gl-rounded-6 gl-mb-3 gl-px-5 gl-py-3 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
+ :class="jobPillClasses"
+ @mouseover="onMouseEnter"
+ @mouseleave="onMouseLeave"
+ >
+ {{ jobName }}
+ </div>
+ </tooltip-on-truncate>
+ </div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 3ba0d7d0120..78771b6a072 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -4,14 +4,14 @@ import { __ } from '~/locale';
import { DRAW_FAILURE, DEFAULT } from '../../constants';
import LinksLayer from '../graph_shared/links_layer.vue';
import JobPill from './job_pill.vue';
-import StagePill from './stage_pill.vue';
+import StageName from './stage_name.vue';
export default {
components: {
GlAlert,
JobPill,
LinksLayer,
- StagePill,
+ StageName,
},
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
BASE_CONTAINER_ID: 'pipeline-graph-container',
@@ -21,6 +21,11 @@ export default {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
+ // The combination of gl-w-full gl-min-w-full and gl-max-w-15 is necessary.
+ // The max width and the width make sure the ellipsis to work and the min width
+ // is for when there is less text than the stage column width (which the width 100% does not fix)
+ jobWrapperClasses:
+ 'gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8 gl-min-w-full gl-max-w-15',
props: {
pipelineData: {
required: true,
@@ -85,23 +90,8 @@ export default {
height: this.$refs[this.$options.CONTAINER_REF].scrollHeight,
};
},
- getStageBackgroundClasses(index) {
- const { length } = this.pipelineStages;
- // It's possible for a graph to have only one stage, in which
- // case we concatenate both the left and right rounding classes
- if (length === 1) {
- return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6 gl-rounded-bottom-right-6 gl-rounded-top-right-6';
- }
-
- if (index === 0) {
- return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6';
- }
-
- if (index === length - 1) {
- return 'gl-rounded-bottom-right-6 gl-rounded-top-right-6';
- }
-
- return '';
+ isFadedOut(jobName) {
+ return this.highlightedJobs.length > 1 && !this.isJobHighlighted(jobName);
},
isJobHighlighted(jobName) {
return this.highlightedJobs.includes(jobName);
@@ -137,7 +127,12 @@ export default {
>
{{ failure.text }}
</gl-alert>
- <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
+ <div
+ :id="containerId"
+ :ref="$options.CONTAINER_REF"
+ class="gl-bg-gray-10 gl-overflow-auto"
+ data-testid="graph-container"
+ >
<links-layer
:pipeline-data="pipelineStages"
:pipeline-id="$options.PIPELINE_ID"
@@ -152,23 +147,17 @@ export default {
:key="`${stage.name}-${index}`"
class="gl-flex-direction-column"
>
- <div
- class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
- :class="getStageBackgroundClasses(index)"
- data-testid="stage-background"
- >
- <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
+ <div class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5">
+ <stage-name :stage-name="stage.name" />
</div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
- >
+ <div :class="$options.jobWrapperClasses">
<job-pill
v-for="group in stage.groups"
:key="group.name"
:job-name="group.name"
:pipeline-id="$options.PIPELINE_ID"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
+ :is-hovered="highlightedJob === group.name"
+ :is-faded-out="isFadedOut(group.name)"
@on-mouse-enter="setHoveredJob"
@on-mouse-leave="removeHoveredJob"
/>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
index df48426f24e..367a18af248 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
@@ -1,4 +1,5 @@
<script>
+import { capitalize, escape } from 'lodash';
import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
@@ -10,26 +11,18 @@ export default {
type: String,
required: true,
},
- isEmpty: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
- emptyClass() {
- return this.isEmpty ? 'gl-bg-gray-200' : 'gl-bg-gray-600';
+ formattedTitle() {
+ return capitalize(escape(this.stageName));
},
},
};
</script>
<template>
<tooltip-on-truncate :title="stageName" truncate-target="child" placement="top">
- <div
- class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill gl-w-20"
- :class="emptyClass"
- >
- {{ stageName }}
+ <div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20">
+ {{ formattedTitle }}
</div>
</tooltip-on-truncate>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index 104a3caab4c..1ce6654e0e9 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -16,7 +16,6 @@ export default {
consuming tasks, so you can spend more time creating.`),
aboutRunnersBtnText: s__('Pipelines|Learn about Runners'),
installRunnersBtnText: s__('Pipelines|Install GitLab Runners'),
- getStartedBtnText: s__('Pipelines|Get started with CI/CD'),
codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'),
codeQualityDescription: s__(`Pipelines|To keep your codebase simple,
readable, and accessible to contributors, use GitLab CI/CD
@@ -55,9 +54,6 @@ export default {
ciHelpPagePath() {
return helpPagePath('ci/quick_start/index.md');
},
- isPipelineEmptyStateTemplatesExperimentActive() {
- return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates'));
- },
isCodeQualityExperimentActive() {
return this.canSetCi && Boolean(getExperimentData('code_quality_walkthrough'));
},
@@ -81,37 +77,8 @@ export default {
</script>
<template>
<div>
- <gitlab-experiment
- v-if="isPipelineEmptyStateTemplatesExperimentActive"
- name="pipeline_empty_state_templates"
- >
- <template #control>
- <gl-empty-state
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- :primary-button-text="$options.i18n.getStartedBtnText"
- :primary-button-link="ciHelpPagePath"
- />
- </template>
- <template #candidate>
- <pipelines-ci-templates />
- </template>
- </gitlab-experiment>
- <gitlab-experiment v-else-if="isCodeQualityExperimentActive" name="code_quality_walkthrough">
- <template #control>
- <gl-empty-state
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- >
- <template #actions>
- <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()">
- {{ $options.i18n.getStartedBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
+ <gitlab-experiment v-if="isCodeQualityExperimentActive" name="code_quality_walkthrough">
+ <template #control><pipelines-ci-templates /></template>
<template #candidate>
<gl-empty-state
:title="$options.i18n.codeQualityTitle"
@@ -127,23 +94,7 @@ export default {
</template>
</gitlab-experiment>
<gitlab-experiment v-else-if="isCiRunnerTemplatesExperimentActive" name="ci_runner_templates">
- <template #control>
- <gl-empty-state
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- >
- <template #actions>
- <gl-button
- :href="ciHelpPagePath"
- variant="confirm"
- @click="trackCiRunnerTemplatesClick('get_started_button_clicked')"
- >
- {{ $options.i18n.getStartedBtnText }}
- </gl-button>
- </template>
- </gl-empty-state>
- </template>
+ <template #control><pipelines-ci-templates /></template>
<template #candidate>
<gl-empty-state
:title="$options.i18n.title"
@@ -169,14 +120,7 @@ export default {
</gl-empty-state>
</template>
</gitlab-experiment>
- <gl-empty-state
- v-else-if="canSetCi"
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- :primary-button-text="$options.i18n.getStartedBtnText"
- :primary-button-link="ciHelpPagePath"
- />
+ <pipelines-ci-templates v-else-if="canSetCi" />
<gl-empty-state
v-else
title=""
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index d7bd2d731b1..5e18f636b52 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -97,7 +97,7 @@ export default {
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<gl-dropdown-item
v-for="(artifact, i) in artifacts"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index bf992b84387..7552ddb61dc 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -13,7 +13,7 @@
*/
import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import eventHub from '../../event_hub';
@@ -83,7 +83,9 @@ export default {
this.$refs.dropdown.hide();
this.isLoading = false;
- Flash(__('Something went wrong on our end.'));
+ createFlash({
+ message: __('Something went wrong on our end.'),
+ });
});
},
isDropdownOpen() {
@@ -118,7 +120,7 @@ export default {
<gl-icon :name="borderlessIcon" />
</span>
</template>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<ul
v-else
class="js-builds-dropdown-list scrollable-menu"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 52c8ef2cf26..fc8f31c5b7e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -60,19 +60,20 @@ export default {
data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
>
- <span class="pipeline-id">#{{ pipeline.id }}</span>
+ #{{ pipeline.id }}
</gl-link>
<div class="label-container">
- <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank">
- <gl-badge
- v-gl-tooltip
- :title="__('This pipeline was triggered by a schedule.')"
- variant="info"
- size="sm"
- data-testid="pipeline-url-scheduled"
- >{{ __('Scheduled') }}</gl-badge
- >
- </gl-link>
+ <gl-badge
+ v-if="isScheduled"
+ v-gl-tooltip
+ :href="pipelineScheduleUrl"
+ target="__blank"
+ :title="__('This pipeline was triggered by a schedule.')"
+ variant="info"
+ size="sm"
+ data-testid="pipeline-url-scheduled"
+ >{{ __('Scheduled') }}</gl-badge
+ >
<gl-badge
v-if="pipeline.flags.latest"
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 8bb2657c161..e3373178239 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -2,7 +2,7 @@
import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { isEqual } from 'lodash';
import createFlash from '~/flash';
-import { getParameterByName } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 147fff52101..36629d9f1f1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -96,7 +96,7 @@ export default {
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false">
{{ $options.i18n.noArtifacts }}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
index c2ec8c57fd7..c6c81d5253b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -1,17 +1,19 @@
<script>
-import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { GlAvatar, GlButton, GlCard, GlSprintf } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale';
-import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants';
+import { STARTER_TEMPLATE_NAME } from '~/pipeline_editor/constants';
+import Tracking from '~/tracking';
export default {
components: {
+ GlAvatar,
GlButton,
GlCard,
GlSprintf,
},
- HELLO_WORLD_TEMPLATE_KEY,
+ mixins: [Tracking.mixin()],
+ STARTER_TEMPLATE_NAME,
i18n: {
cta: s__('Pipelines|Use template'),
testTemplates: {
@@ -19,10 +21,10 @@ export default {
subtitle: s__(
'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
),
- helloWorld: {
- title: s__('Pipelines|“Hello world” with GitLab CI/CD'),
+ gettingStarted: {
+ title: s__('Pipelines|Get started with GitLab CI/CD'),
description: s__(
- 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.',
+ 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a basic 3 stage CI/CD pipeline.',
),
},
},
@@ -34,31 +36,30 @@ export default {
description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
},
},
- inject: ['addCiYmlPath', 'suggestedCiTemplates'],
+ inject: ['pipelineEditorPath', 'suggestedCiTemplates'],
data() {
const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
return {
name,
logo,
- link: mergeUrlParams({ template: name }, this.addCiYmlPath),
+ link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
description: sprintf(this.$options.i18n.templates.description, { name }),
};
});
return {
templates,
- helloWorldTemplateUrl: mergeUrlParams(
- { template: HELLO_WORLD_TEMPLATE_KEY },
- this.addCiYmlPath,
+ gettingStartedTemplateUrl: mergeUrlParams(
+ { template: STARTER_TEMPLATE_NAME },
+ this.pipelineEditorPath,
),
};
},
methods: {
trackEvent(template) {
- const tracking = new ExperimentTracking('pipeline_empty_state_templates', {
+ this.track('template_clicked', {
label: template,
});
- tracking.event('template_clicked');
},
},
};
@@ -81,18 +82,18 @@ export default {
<div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
<div class="gl-mb-3">
<strong class="gl-text-gray-800 gl-mb-2">{{
- $options.i18n.testTemplates.helloWorld.title
+ $options.i18n.testTemplates.gettingStarted.title
}}</strong>
</div>
- <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p>
+ <p class="gl-font-sm">{{ $options.i18n.testTemplates.gettingStarted.description }}</p>
</div>
<gl-button
category="primary"
variant="confirm"
- :href="helloWorldTemplateUrl"
+ :href="gettingStartedTemplateUrl"
data-testid="test-template-link"
- @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)"
+ @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
>
{{ $options.i18n.cta }}
</gl-button>
@@ -109,11 +110,12 @@ export default {
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
>
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
- <img
- width="64"
- height="64"
+ <gl-avatar
:src="template.logo"
- class="gl-mr-6"
+ :size="64"
+ class="gl-mr-6 gl-bg-white dark-mode-override"
+ shape="rect"
+ :alt="template.name"
data-testid="template-logo"
/>
<div class="gl-flex-direction-row">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index 15ff7da35e1..5409e68cdc4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -60,7 +60,7 @@ export default {
@input="searchBranches"
>
<template #suggestions>
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="(branch, index) in branches"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
index af62c492748..afcdd63b664 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
@@ -55,7 +55,7 @@ export default {
<template>
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags">
<template #suggestions>
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag">
{{ tag }}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index bc661f37493..33115d72b9c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -98,7 +98,7 @@ export default {
}}</gl-filtered-search-suggestion>
<gl-dropdown-divider />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="user in users"
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 01705e7726f..21b114825a6 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -35,6 +35,3 @@ export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const CHILD_VIEW = 'child';
-
-// The key of the template is the same as the filename
-export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World';
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
index 9f15b6c4ae3..5c34f4e4f7e 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
@@ -13,7 +13,9 @@ export default {
})
.catch(() => {
this.mediator.store.toggleLoading(pipeline);
- flash(__('An error occurred while fetching the pipeline.'));
+ createFlash({
+ message: __('An error occurred while fetching the pipeline.'),
+ });
});
},
/**
@@ -53,9 +55,11 @@ export default {
requestRefreshPipelineGraph() {
// When an action is clicked
// (whether in the dropdown or in the main nodes, we refresh the big graph)
- this.mediator
- .refreshPipeline()
- .catch(() => flash(__('An error occurred while making the request.')));
+ this.mediator.refreshPipeline().catch(() =>
+ createFlash({
+ message: __('An error occurred while making the request.'),
+ }),
+ );
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 9ab4753fec8..e8d5ed175ba 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
@@ -96,14 +96,18 @@ export default async function initPipelineDetailsBundle() {
try {
createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
} catch {
- Flash(__('An error occurred while loading a section of this page.'));
+ createFlash({
+ message: __('An error occurred while loading a section of this page.'),
+ });
}
if (canShowNewPipelineDetails) {
try {
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
} catch {
- Flash(__('An error occurred while loading the pipeline.'));
+ createFlash({
+ message: __('An error occurred while loading the pipeline.'),
+ });
}
} else {
const { default: PipelinesMediator } = await import(
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index 09637c25654..72c4fedc64c 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '~/flash';
import Poll from '../lib/utils/poll';
import { __ } from '../locale';
import PipelineService from './services/pipeline_service';
@@ -47,7 +47,9 @@ export default class pipelinesMediator {
errorCallback() {
this.state.isLoading = false;
- Flash(__('An error occurred while fetching the pipeline.'));
+ createFlash({
+ message: __('An error occurred while fetching the pipeline.'),
+ });
}
refreshPipeline() {
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
index c3be487caae..7a922acd0b3 100644
--- a/app/assets/javascripts/pipelines/pipeline_shared_client.js
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -5,6 +5,7 @@ export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
+ assumeImmutableResults: true,
useGet: true,
},
),
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 925a96ea1aa..c4c2b5f2927 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -29,7 +29,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
errorStateSvgPath,
noPipelinesSvgPath,
newPipelinePath,
- addCiYmlPath,
+ pipelineEditorPath,
suggestedCiTemplates,
canCreatePipeline,
hasGitlabCi,
@@ -44,7 +44,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
return new Vue({
el,
provide: {
- addCiYmlPath,
+ pipelineEditorPath,
artifactsEndpoint,
artifactsEndpointPlaceholder,
suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 07d8f3cc5f1..a0129dd536b 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -131,7 +131,8 @@ export default {
<div class="col-lg-8">
<div class="form-group">
<gl-button
- variant="success"
+ category="primary"
+ variant="confirm"
name="commit"
type="submit"
:disabled="!isSubmitEnabled"
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index dad2c18fb18..c49ade2bbb8 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,11 +1,11 @@
import $ from 'jquery';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { Rails } from '~/lib/utils/rails_ujs';
import TimezoneDropdown, {
formatTimezone,
} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
-import { deprecatedCreateFlash as flash } from '../flash';
export default class Profile {
constructor({ form } = {}) {
@@ -83,14 +83,21 @@ export default class Profile {
this.updateHeaderAvatar();
}
- flash(data.message, 'notice');
+ createFlash({
+ message: data.message,
+ type: 'notice',
+ });
})
.then(() => {
window.scrollTo(0, 0);
// Enable submit button after requests ends
self.form.find(':input[disabled]').enable();
})
- .catch((error) => flash(error.message));
+ .catch((error) =>
+ createFlash({
+ message: error.message,
+ }),
+ );
}
updateHeaderAvatar() {
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index f44661cb139..d295c06928f 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,7 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -88,7 +88,11 @@ export default class ProjectFindFile {
this.findFile();
this.element.find('.files-slider tr.tree-item').eq(0).addClass('selected').focus();
})
- .catch(() => flash(__('An error occurred while loading filenames')));
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while loading filenames'),
+ }),
+ );
}
// render result
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index e6dd4145cb8..f7804c2faa4 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { fixTitle } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
@@ -60,7 +60,11 @@ export default class ProjectLabelSubscription {
return button;
});
})
- .catch(() => flash(__('There was an error subscribing to this label.')));
+ .catch(() =>
+ createFlash({
+ message: __('There was an error subscribing to this label.'),
+ }),
+ );
}
static setNewTitle($button, originalTitle, newStatus) {
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index cc5bc703994..52da8aaba4d 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -99,7 +99,7 @@ export default {
{{ branch }}
</gl-dropdown-item>
<gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
- <gl-loading-icon class="gl-mx-auto" />
+ <gl-loading-icon size="sm" class="gl-mx-auto" />
</gl-dropdown-text>
<gl-dropdown-text
v-if="!filteredResults.length && !isFetching"
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 1566232751d..c8a0a3417f3 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -9,8 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
const tooltipMessage = __('Searching by both author and message is currently not supported.');
@@ -52,7 +51,7 @@ export default {
},
mounted() {
this.fetchAuthors();
- const params = urlParamsToObject(window.location.search);
+ const params = queryToObject(window.location.search);
const { search: searchParam, author: authorParam } = params;
const commitsSearchInput = this.projectCommitsEl.querySelector('#commits-search');
diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue
index 81d23a563e2..06711e4025a 100644
--- a/app/assets/javascripts/projects/components/project_delete_button.vue
+++ b/app/assets/javascripts/projects/components/project_delete_button.vue
@@ -24,9 +24,6 @@ export default {
alertBody: __(
'Once a project is permanently deleted, it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd}, including issues, merge requests etc.',
),
- modalBody: __(
- "This action cannot be undone. You will lose this project's repository and all related resources, including issues, merge requests, etc.",
- ),
},
};
</script>
@@ -46,7 +43,6 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
- <p>{{ $options.strings.modalBody }}</p>
</template>
</shared-delete-button>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index 1c4413bef71..0b0560f63c1 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -225,11 +225,21 @@ export default {
{
name: 'success',
data: this.mergeLabelsAndValues(labels, success),
+ areaStyle: {
+ color: this.$options.successColor,
+ },
+ lineStyle: {
+ color: this.$options.successColor,
+ },
+ itemStyle: {
+ color: this.$options.successColor,
+ },
},
],
};
},
},
+ successColor: '#608b2f',
chartContainerHeight: CHART_CONTAINER_HEIGHT,
timesChartOptions: {
height: INNER_CHART_HEIGHT,
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 04ea6f760f6..ee02f446795 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -74,6 +74,7 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
+ const $projectImportUrlWarning = $('.js-import-url-warning');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -134,7 +135,25 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
+ function updateUrlPathWarningVisibility() {
+ const url = $projectImportUrl.val();
+ const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/;
+ const isUrlValid = URL_PATTERN.test(url);
+ $projectImportUrlWarning.toggleClass('hide', isUrlValid);
+ }
+
+ let isProjectImportUrlDirty = false;
+ $projectImportUrl.on('blur', () => {
+ isProjectImportUrlDirty = true;
+ updateUrlPathWarningVisibility();
+ });
+ $projectImportUrl.on('keyup', () => {
+ deriveProjectPathFromUrl($projectImportUrl);
+ // defer error message till first input blur
+ if (isProjectImportUrlDirty) {
+ updateUrlPathWarningVisibility();
+ }
+ });
$('.js-import-git-toggle-button').on('click', () => {
const $projectMirror = $('#project_mirror');
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index 0786a74f6b1..e4edb950a1e 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -1,15 +1,23 @@
<script>
import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.');
+const REQUIRES_VALIDATION_TEXT = s__(
+ `Billings|Shared runners cannot be enabled until a valid credit card is on file.`,
+);
export default {
+ i18n: {
+ REQUIRES_VALIDATION_TEXT,
+ },
components: {
GlAlert,
GlToggle,
GlTooltip,
+ CcValidationRequiredAlert: () =>
+ import('ee_component/billings/components/cc_validation_required_alert.vue'),
},
props: {
isDisabledAndUnoverridable: {
@@ -20,6 +28,10 @@ export default {
type: Boolean,
required: true,
},
+ isCreditCardValidationRequired: {
+ type: Boolean,
+ required: false,
+ },
updatePath: {
type: String,
required: true,
@@ -28,14 +40,24 @@ export default {
data() {
return {
isLoading: false,
- isSharedRunnerEnabled: false,
+ isSharedRunnerEnabled: this.isEnabled,
errorMessage: null,
+ successfulValidation: false,
};
},
- created() {
- this.isSharedRunnerEnabled = this.isEnabled;
+ computed: {
+ showCreditCardValidation() {
+ return (
+ this.isCreditCardValidationRequired &&
+ !this.isSharedRunnerEnabled &&
+ !this.successfulValidation
+ );
+ },
},
methods: {
+ creditCardValidated() {
+ this.successfulValidation = true;
+ },
toggleSharedRunners() {
this.isLoading = true;
this.errorMessage = null;
@@ -61,16 +83,25 @@ export default {
<gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
- <div ref="sharedRunnersToggle">
- <gl-toggle
- :disabled="isDisabledAndUnoverridable"
- :is-loading="isLoading"
- :label="__('Enable shared runners for this project')"
- :value="isSharedRunnerEnabled"
- data-testid="toggle-shared-runners"
- @change="toggleSharedRunners"
- />
- </div>
+
+ <cc-validation-required-alert
+ v-if="showCreditCardValidation"
+ class="gl-pb-5"
+ :custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT"
+ @verifiedCreditCard="creditCardValidated"
+ />
+
+ <gl-toggle
+ v-else
+ ref="sharedRunnersToggle"
+ :disabled="isDisabledAndUnoverridable"
+ :is-loading="isLoading"
+ :label="__('Enable shared runners for this project')"
+ :value="isSharedRunnerEnabled"
+ data-testid="toggle-shared-runners"
+ @change="toggleSharedRunners"
+ />
+
<gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle">
{{ __('Shared runners are disabled on group level') }}
</gl-tooltip>
diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
index eaeb5848b68..5ca864a412b 100644
--- a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
+++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
@@ -4,7 +4,12 @@ import SharedRunnersToggle from '~/projects/settings/components/shared_runners_t
export default (containerId = 'toggle-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
- const { isDisabledAndUnoverridable, isEnabled, updatePath } = containerEl.dataset;
+ const {
+ isDisabledAndUnoverridable,
+ isEnabled,
+ updatePath,
+ isCreditCardValidationRequired,
+ } = containerEl.dataset;
return new Vue({
el: containerEl,
@@ -13,6 +18,7 @@ export default (containerId = 'toggle-shared-runners-form') => {
props: {
isDisabledAndUnoverridable: parseBoolean(isDisabledAndUnoverridable),
isEnabled: parseBoolean(isEnabled),
+ isCreditCardValidationRequired: parseBoolean(isCreditCardValidationRequired),
updatePath,
},
});
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index fb00f58abae..4c083ed5496 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
@@ -9,6 +9,9 @@ export default {
GlAlert,
ServiceDeskSetting,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
inject: {
initialIsEnabled: {
default: false,
@@ -121,7 +124,7 @@ export default {
<template>
<div>
<gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
- {{ alertMessage }}
+ <span v-safe-html="alertMessage"></span>
</gl-alert>
<service-desk-setting
:is-enabled="isEnabled"
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 3294a37c26a..34d53e2de0c 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -144,7 +144,7 @@ export default {
</span>
</template>
<template v-else>
- <gl-loading-icon :inline="true" />
+ <gl-loading-icon size="sm" :inline="true" />
<span class="sr-only">{{ __('Fetching incoming email') }}</span>
</template>
diff --git a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
new file mode 100644
index 00000000000..0b398eddc9c
--- /dev/null
+++ b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlBanner } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'TerraformNotification',
+ i18n: {
+ title: s__('TerraformBanner|Using Terraform? Try the GitLab Managed Terraform State'),
+ description: s__(
+ 'TerraformBanner|The GitLab managed Terraform state backend can store your Terraform state easily and securely, and spares you from setting up additional remote resources. Its features include: versioning, encryption of the state file both in transit and at rest, locking, and remote Terraform plan/apply execution.',
+ ),
+ buttonText: s__("TerraformBanner|Learn more about GitLab's Backend State"),
+ },
+ components: {
+ GlBanner,
+ },
+ props: {
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isVisible: true,
+ };
+ },
+ computed: {
+ bannerDissmisedKey() {
+ return `terraform_notification_dismissed_for_project_${this.projectId}`;
+ },
+ docsUrl() {
+ return helpPagePath('user/infrastructure/terraform_state');
+ },
+ },
+ created() {
+ if (parseBoolean(getCookie(this.bannerDissmisedKey))) {
+ this.isVisible = false;
+ }
+ },
+ methods: {
+ handleClose() {
+ setCookie(this.bannerDissmisedKey, true);
+ this.isVisible = false;
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="isVisible">
+ <div class="gl-py-5">
+ <gl-banner
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ :button-link="docsUrl"
+ variant="introduction"
+ @close="handleClose"
+ >
+ <p>{{ $options.i18n.description }}</p>
+ </gl-banner>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/terraform_notification/index.js b/app/assets/javascripts/projects/terraform_notification/index.js
new file mode 100644
index 00000000000..eb04f109a8e
--- /dev/null
+++ b/app/assets/javascripts/projects/terraform_notification/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import TerraformNotification from './components/terraform_notification.vue';
+
+export default () => {
+ const el = document.querySelector('.js-terraform-notification');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectId } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(TerraformNotification, { props: { projectId: Number(projectId) } }),
+ });
+};
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index f3d12e0dd00..f6f409873c8 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { __, s__, sprintf } from '~/locale';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
@@ -57,7 +57,9 @@ export default {
group: 'notfound',
};
this.isLoading = false;
- Flash(s__('Something went wrong on our end'));
+ createFlash({
+ message: s__('Something went wrong on our end'),
+ });
},
initPolling() {
this.poll = new Poll({
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 726ddba1014..d0d2c1400a7 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import CreateItemDropdown from '~/create_item_dropdown';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -135,6 +135,10 @@ export default class ProtectedBranchCreate {
.then(() => {
window.location.reload();
})
- .catch(() => Flash(__('Failed to protect the branch')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to protect the branch'),
+ }),
+ );
}
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index ae7855d4638..1fe9a753e1e 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '../flash';
+import createFlash from '~/flash';
import axios from '../lib/utils/axios_utils';
import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
@@ -49,7 +49,9 @@ export default class ProtectedTagEdit {
this.$allowedToCreateDropdownButton.enable();
window.scrollTo({ top: 0, behavior: 'smooth' });
- flash(FAILED_TO_UPDATE_TAG_MESSAGE);
+ createFlash({
+ message: FAILED_TO_UPDATE_TAG_MESSAGE,
+ });
});
}
}
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index 44d0f50b832..1cef986a83d 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -1,3 +1,4 @@
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES';
@@ -7,7 +8,7 @@ export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, RE
export const X_TOTAL_HEADER = 'x-total';
-export const SEARCH_DEBOUNCE_MS = 250;
+export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const DEFAULT_I18N = Object.freeze({
dropdownHeader: __('Select Git revision'),
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue
new file mode 100644
index 00000000000..8d9e221af4c
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/list_page/cleanup_status.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import {
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ CLEANUP_STATUS_SCHEDULED,
+ CLEANUP_STATUS_ONGOING,
+ CLEANUP_STATUS_UNFINISHED,
+ UNFINISHED_STATUS,
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+} from '../../constants/index';
+
+export default {
+ name: 'CleanupStatus',
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [UNFINISHED_STATUS, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS].includes(
+ value,
+ );
+ },
+ },
+ },
+ i18n: {
+ CLEANUP_STATUS_SCHEDULED,
+ CLEANUP_STATUS_ONGOING,
+ CLEANUP_STATUS_UNFINISHED,
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ },
+ computed: {
+ showStatus() {
+ return this.status !== UNSCHEDULED_STATUS;
+ },
+ failedDelete() {
+ return this.status === UNFINISHED_STATUS;
+ },
+ statusText() {
+ return this.$options.i18n[`CLEANUP_STATUS_${this.status}`];
+ },
+ expireIconClass() {
+ return this.failedDelete ? 'gl-text-orange-500' : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="showStatus" class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon name="expire" data-testid="main-icon" :class="expireIconClass" />
+ <span class="gl-mx-2">
+ {{ statusText }}
+ </span>
+ <gl-icon
+ v-if="failedDelete"
+ v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }"
+ :size="14"
+ class="gl-text-black-normal"
+ data-testid="extra-info"
+ name="information"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index 930ad01c758..c1ec523574a 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -16,6 +16,7 @@ import {
ROOT_IMAGE_TEXT,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
+import CleanupStatus from './cleanup_status.vue';
export default {
name: 'ImageListRow',
@@ -26,6 +27,7 @@ export default {
GlIcon,
ListItem,
GlSkeletonLoader,
+ CleanupStatus,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -112,27 +114,24 @@ export default {
:title="item.location"
category="tertiary"
/>
- <gl-icon
- v-if="warningIconText"
- v-gl-tooltip="{ title: warningIconText }"
- data-testid="warning-icon"
- name="warning"
- class="gl-text-orange-500"
- />
</template>
<template #left-secondary>
- <span
- v-if="!metadataLoading"
- class="gl-display-flex gl-align-items-center"
- data-testid="tags-count"
- >
- <gl-icon name="tag" class="gl-mr-2" />
- <gl-sprintf :message="tagsCountText">
- <template #count>
- {{ item.tagsCount }}
- </template>
- </gl-sprintf>
- </span>
+ <template v-if="!metadataLoading">
+ <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="tagsCountText">
+ <template #count>
+ {{ item.tagsCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <cleanup-status
+ v-if="item.expirationPolicyCleanupStatus"
+ class="ml-2"
+ :status="item.expirationPolicyCleanupStatus"
+ />
+ </template>
<div v-else class="gl-w-full">
<gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet">
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 5dcc042a9c4..9b4c06349e2 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -89,6 +89,10 @@ export const CLEANUP_DISABLED_TOOLTIP = s__(
'ContainerRegistry|Cleanup is disabled for this project',
);
+export const CLEANUP_STATUS_SCHEDULED = s__('ContainerRegistry|Cleanup will run soon');
+export const CLEANUP_STATUS_ONGOING = s__('ContainerRegistry|Cleanup is ongoing');
+export const CLEANUP_STATUS_UNFINISHED = s__('ContainerRegistry|Cleanup timed out');
+
export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
);
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 825a4a02b71..8f486fb1b07 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
-import sortableConfig from 'ee_else_ce/sortable/sortable_config';
+import sortableConfig from '~/sortable/sortable_config';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
export default {
@@ -102,7 +102,12 @@ export default {
class="related-issues-loading-icon"
data-qa-selector="related_issues_loading_placeholder"
>
- <gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="gl-mt-2" />
+ <gl-loading-icon
+ ref="loadingIcon"
+ size="sm"
+ label="Fetching linked issues"
+ class="gl-mt-2"
+ />
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index ccb92d2aedc..6fb1d1ed365 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -94,7 +94,7 @@ export default {
</div>
<div>
<div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon">
- <gl-loading-icon label="Fetching related merge requests" class="py-2" />
+ <gl-loading-icon size="sm" label="Fetching related merge requests" class="py-2" />
</div>
<ul v-else class="content-list related-items-list">
<li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 3774f97a060..39140216bc5 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -1,8 +1,7 @@
<script>
import { GlButton, GlFormInput, GlFormGroup, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import { getParameterByName } from '~/lib/utils/common_utils';
-import { isSameOriginUrl } from '~/lib/utils/url_utility';
+import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 31d335fa15d..c2c91f406a1 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { getParameterByName } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
index ea0aa409577..f49c44a399f 100644
--- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue
+++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
@@ -1,12 +1,12 @@
<script>
import { GlButton } from '@gitlab/ui';
+import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
import createFlash from '~/flash';
-import { historyPushState, getParameterByName } from '~/lib/utils/common_utils';
+import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
+import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
import { convertAllReleasesGraphQLResponse } from '~/releases/util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index 3a742db7d9e..3a927dfc756 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -1,4 +1,5 @@
fragment Release on Release {
+ __typename
name
tagName
tagPath
@@ -7,15 +8,20 @@ fragment Release on Release {
createdAt
upcomingRelease
assets {
+ __typename
count
sources {
+ __typename
nodes {
+ __typename
format
url
}
}
links {
+ __typename
nodes {
+ __typename
id
name
url
@@ -26,13 +32,16 @@ fragment Release on Release {
}
}
evidences {
+ __typename
nodes {
+ __typename
filepath
collectedAt
sha
}
}
links {
+ __typename
editUrl
selfUrl
openedIssuesUrl
@@ -42,22 +51,27 @@ fragment Release on Release {
closedMergeRequestsUrl
}
commit {
+ __typename
sha
webUrl
title
}
author {
+ __typename
webUrl
avatarUrl
username
}
milestones {
+ __typename
nodes {
+ __typename
id
title
description
webPath
stats {
+ __typename
totalIssuesCount
closedIssuesCount
}
diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
index 47c5afefd78..75a73acb9ae 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
@@ -9,6 +9,7 @@ fragment ReleaseForEditing on Release {
name
url
linkType
+ directAssetPath
}
}
}
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index 10e4d883e62..f2d89dbe682 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,5 +1,11 @@
#import "../fragments/release.fragment.graphql"
+# This query is identical to
+# `app/graphql/queries/releases/all_releases.query.graphql`.
+# These two queries should be kept in sync.
+# When the `releases_index_apollo_client` feature flag is
+# removed, this query should be removed entirely.
+
query allReleases(
$fullPath: ID!
$first: Int
@@ -9,11 +15,14 @@ query allReleases(
$sort: ReleaseSort
) {
project(fullPath: $fullPath) {
+ __typename
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
+ __typename
nodes {
...Release
}
pageInfo {
+ __typename
startCursor
hasPreviousPage
hasNextPage
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 5955ec3352e..576f099248e 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -165,6 +165,7 @@ const createReleaseLink = async ({ state, link }) => {
name: link.name,
url: link.url,
linkType: link.linkType.toUpperCase(),
+ directAssetPath: link.directAssetPath,
},
},
});
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index 56f46a3938e..6014d9d6ad8 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -1,3 +1,4 @@
+import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
import TestIssueBody from '../grouped_test_report/components/test_issue_body.vue';
@@ -13,3 +14,11 @@ export const componentNames = {
CodequalityIssueBody: CodequalityIssueBody.name,
TestIssueBody: TestIssueBody.name,
};
+
+export const iconComponents = {
+ IssueStatusIcon,
+};
+
+export const iconComponentNames = {
+ IssueStatusIcon: IssueStatusIcon.name,
+};
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index df20d5c19ba..8871da8fbd7 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -1,12 +1,16 @@
<script>
-import { components, componentNames } from 'ee_else_ce/reports/components/issue_body';
-import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
+import {
+ components,
+ componentNames,
+ iconComponents,
+ iconComponentNames,
+} from 'ee_else_ce/reports/components/issue_body';
export default {
name: 'ReportItem',
components: {
- IssueStatusIcon,
...components,
+ ...iconComponents,
},
props: {
issue: {
@@ -19,6 +23,12 @@ export default {
default: '',
validator: (value) => value === '' || Object.values(componentNames).includes(value),
},
+ iconComponent: {
+ type: String,
+ required: false,
+ default: iconComponentNames.IssueStatusIcon,
+ validator: (value) => Object.values(iconComponentNames).includes(value),
+ },
// failed || success
status: {
type: String,
@@ -48,11 +58,12 @@ export default {
class="report-block-list-issue align-items-center"
data-qa-selector="report_item_row"
>
- <issue-status-icon
+ <component
+ :is="iconComponent"
v-if="showReportSectionStatusIcon"
:status="status"
:status-icon-size="statusIconSize"
- class="gl-mr-3"
+ class="gl-mr-2"
/>
<component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index acd90ebf1b1..7f7ea2adc0e 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -16,6 +16,7 @@ export const STATUS_NEUTRAL = 'neutral';
export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
+export const ICON_PENDING = 'pending';
export const status = {
LOADING,
diff --git a/app/assets/javascripts/repository/components/blob_replace.vue b/app/assets/javascripts/repository/components/blob_button_group.vue
index 91d7811eb6d..273825b996a 100644
--- a/app/assets/javascripts/repository/components/blob_replace.vue
+++ b/app/assets/javascripts/repository/components/blob_button_group.vue
@@ -1,18 +1,22 @@
<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale';
import getRefMixin from '../mixins/get_ref';
+import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
export default {
i18n: {
replace: __('Replace'),
replacePrimaryBtnText: __('Replace file'),
+ delete: __('Delete'),
},
components: {
+ GlButtonGroup,
GlButton,
UploadBlobModal,
+ DeleteBlobModal,
},
directives: {
GlModal: GlModalDirective,
@@ -39,31 +43,50 @@ export default {
type: String,
required: true,
},
+ deletePath: {
+ type: String,
+ required: true,
+ },
canPushCode: {
type: Boolean,
required: true,
},
+ emptyRepo: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
replaceModalId() {
return uniqueId('replace-modal');
},
- title() {
+ replaceModalTitle() {
return sprintf(__('Replace %{name}'), { name: this.name });
},
+ deleteModalId() {
+ return uniqueId('delete-modal');
+ },
+ deleteModalTitle() {
+ return sprintf(__('Delete %{name}'), { name: this.name });
+ },
},
};
</script>
<template>
<div class="gl-mr-3">
- <gl-button v-gl-modal="replaceModalId">
- {{ $options.i18n.replace }}
- </gl-button>
+ <gl-button-group>
+ <gl-button v-gl-modal="replaceModalId">
+ {{ $options.i18n.replace }}
+ </gl-button>
+ <gl-button v-gl-modal="deleteModalId">
+ {{ $options.i18n.delete }}
+ </gl-button>
+ </gl-button-group>
<upload-blob-modal
:modal-id="replaceModalId"
- :modal-title="title"
- :commit-message="title"
+ :modal-title="replaceModalTitle"
+ :commit-message="replaceModalTitle"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
@@ -71,5 +94,15 @@ export default {
:replace-path="replacePath"
:primary-btn-text="$options.i18n.replacePrimaryBtnText"
/>
+ <delete-blob-modal
+ :modal-id="deleteModalId"
+ :modal-title="deleteModalTitle"
+ :delete-path="deletePath"
+ :commit-message="deleteModalTitle"
+ :target-branch="targetBranch || ref"
+ :original-branch="originalBranch || ref"
+ :can-push-code="canPushCode"
+ :empty-repo="emptyRepo"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 7fbf331d585..09ac60c94c7 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -5,16 +5,19 @@ import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import blobInfoQuery from '../queries/blob_info.query.graphql';
-import BlobHeaderEdit from './blob_header_edit.vue';
-import BlobReplace from './blob_replace.vue';
+import BlobButtonGroup from './blob_button_group.vue';
+import BlobEdit from './blob_edit.vue';
+import { loadViewer, viewerProps } from './blob_viewers';
export default {
components: {
BlobHeader,
- BlobHeaderEdit,
- BlobReplace,
+ BlobEdit,
+ BlobButtonGroup,
BlobContent,
GlLoadingIcon,
},
@@ -31,9 +34,12 @@ export default {
this.switchViewer(
this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
);
+ if (this.hasRichViewer && !this.blobViewer) {
+ this.loadLegacyViewer();
+ }
},
error() {
- createFlash({ message: __('An error occurred while loading the file. Please try again.') });
+ this.displayError();
},
},
},
@@ -54,9 +60,16 @@ export default {
},
data() {
return {
+ legacyRichViewer: null,
+ isBinary: false,
+ isLoadingLegacyViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
project: {
+ userPermissions: {
+ pushCode: false,
+ },
repository: {
+ empty: true,
blobs: {
nodes: [
{
@@ -77,10 +90,10 @@ export default {
canLock: false,
isLocked: false,
lockLink: '',
- canModifyBlob: true,
forkPath: '',
simpleViewer: {},
richViewer: null,
+ webPath: '',
},
],
},
@@ -90,10 +103,10 @@ export default {
},
computed: {
isLoggedIn() {
- return Boolean(gon.current_user_id);
+ return isLoggedIn();
},
isLoading() {
- return this.$apollo.queries.project.loading;
+ return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer;
},
blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes;
@@ -110,8 +123,30 @@ export default {
hasRenderError() {
return Boolean(this.viewer.renderError);
},
+ blobViewer() {
+ const { fileType } = this.viewer;
+ return loadViewer(fileType);
+ },
+ viewerProps() {
+ const { fileType } = this.viewer;
+ return viewerProps(fileType, this.blobInfo);
+ },
},
methods: {
+ loadLegacyViewer() {
+ this.isLoadingLegacyViewer = true;
+ axios
+ .get(`${this.blobInfo.webPath}?format=json&viewer=rich`)
+ .then(({ data: { html, binary } }) => {
+ this.legacyRichViewer = html;
+ this.isBinary = binary;
+ this.isLoadingLegacyViewer = false;
+ })
+ .catch(() => this.displayError());
+ },
+ displayError() {
+ createFlash({ message: __('An error occurred while loading the file. Please try again.') });
+ },
switchViewer(newViewer) {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
},
@@ -121,36 +156,42 @@ export default {
<template>
<div>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<div v-if="blobInfo && !isLoading" class="file-holder">
<blob-header
:blob="blobInfo"
- :hide-viewer-switcher="!hasRichViewer"
+ :hide-viewer-switcher="!hasRichViewer || isBinary"
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
@viewer-changed="switchViewer"
>
<template #actions>
- <blob-header-edit
+ <blob-edit
+ v-if="!isBinary"
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
/>
- <blob-replace
+ <blob-button-group
v-if="isLoggedIn"
:path="path"
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
- :can-push-code="blobInfo.canModifyBlob"
+ :delete-path="blobInfo.webPath"
+ :can-push-code="project.userPermissions.pushCode"
+ :empty-repo="project.repository.empty"
/>
</template>
</blob-header>
<blob-content
+ v-if="!blobViewer"
+ :rich-viewer="legacyRichViewer"
:blob="blobInfo"
:content="blobInfo.rawTextBlob"
:is-raw-content="true"
:active-viewer="viewer"
:loading="false"
/>
+ <component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_header_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue
index 3d97ebe89e4..3d97ebe89e4 100644
--- a/app/assets/javascripts/repository/components/blob_header_edit.vue
+++ b/app/assets/javascripts/repository/components/blob_edit.vue
diff --git a/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
new file mode 100644
index 00000000000..48fa33eb558
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/download_viewer.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { sprintf, __ } from '~/locale';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ fileName: {
+ type: String,
+ required: true,
+ },
+ filePath: {
+ type: String,
+ required: true,
+ },
+ fileSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ downloadFileSize() {
+ return numberToHumanSize(this.fileSize);
+ },
+ downloadText() {
+ if (this.fileSize > 0) {
+ return sprintf(__('Download (%{fileSizeReadable})'), {
+ fileSizeReadable: this.downloadFileSize,
+ });
+ }
+ return __('Download');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-center gl-py-13 gl-bg-gray-50">
+ <gl-link :href="filePath" rel="nofollow" :download="fileName" target="_blank">
+ <div>
+ <gl-icon :size="16" name="download" class="gl-text-gray-900" />
+ </div>
+ <h4>{{ downloadText }}</h4>
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue
new file mode 100644
index 00000000000..53210cbcc93
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/empty_viewer.vue
@@ -0,0 +1,3 @@
+<template>
+ <div class="nothing-here-block">{{ __('Empty file') }}</div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
new file mode 100644
index 00000000000..4e16b16041f
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -0,0 +1,27 @@
+export const loadViewer = (type) => {
+ switch (type) {
+ case 'empty':
+ return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
+ case 'text':
+ return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue');
+ case 'download':
+ return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
+ default:
+ return null;
+ }
+};
+
+export const viewerProps = (type, blob) => {
+ return {
+ text: {
+ content: blob.rawTextBlob,
+ fileName: blob.name,
+ readOnly: true,
+ },
+ download: {
+ fileName: blob.name,
+ filePath: blob.rawPath,
+ fileSize: blob.rawSize,
+ },
+ }[type];
+};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue
new file mode 100644
index 00000000000..57fc979a56e
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ components: {
+ SourceEditor: () =>
+ import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
+ },
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ fileName: {
+ type: String,
+ required: true,
+ },
+ readOnly: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" />
+</template>
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
new file mode 100644
index 00000000000..6599d99d7bd
--- /dev/null
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+import {
+ SECONDARY_OPTIONS_TEXT,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+} from '../constants';
+
+export default {
+ csrf,
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ },
+ i18n: {
+ PRIMARY_OPTIONS_TEXT: __('Delete file'),
+ SECONDARY_OPTIONS_TEXT,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ deletePath: {
+ type: String,
+ required: true,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ originalBranch: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ emptyRepo: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ error: '',
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ variant: 'danger',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: this.$options.i18n.SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode && this.target !== this.originalBranch;
+ },
+ formCompleted() {
+ return this.commit && this.target;
+ },
+ },
+ methods: {
+ submitForm(e) {
+ e.preventDefault(); // Prevent modal from closing
+ this.loading = true;
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="modalTitle"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary="submitForm"
+ >
+ <form ref="form" :action="deletePath" method="post">
+ <input type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <template v-if="emptyRepo">
+ <!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name'
+ Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 -->
+ <input type="hidden" name="branch_name" :value="originalBranch" />
+ </template>
+ <template v-else>
+ <input type="hidden" name="original_branch" :value="originalBranch" />
+ <!-- Once "push to branch" permission is made available, will need to add to conditional
+ Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 -->
+ <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" />
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ </template>
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index ca5711de49c..69eefc807d7 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -70,7 +70,7 @@ export default {
);
},
showParentRow() {
- return !this.isLoading && ['', '/'].indexOf(this.path) === -1;
+ return ['', '/'].indexOf(this.path) === -1;
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 62f863db871..82c18d13a6a 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -186,6 +186,8 @@ export default {
:is="linkComponent"
ref="link"
v-gl-hover-load="handlePreload"
+ v-gl-tooltip:tooltip-container
+ :title="fullPath"
:to="routerLinkTo"
:href="url"
:class="{
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 794a8a85cc5..c861fb8dd06 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,8 +1,9 @@
<script>
import filesQuery from 'shared_queries/repository/files.query.graphql';
import createFlash from '~/flash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
-import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT } from '../constants';
+import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import { readmeFile } from '../utils/readme';
@@ -14,7 +15,7 @@ export default {
FileTable,
FilePreview,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
apollo: {
projectPath: {
query: projectPathQuery,
@@ -36,6 +37,7 @@ export default {
return {
projectPath: '',
nextPageCursor: '',
+ pagesLoaded: 1,
entries: {
trees: [],
submodules: [],
@@ -44,16 +46,28 @@ export default {
isLoadingFiles: false,
isOverLimit: false,
clickedShowMore: false,
- pageSize: TREE_PAGE_SIZE,
fetchCounter: 0,
};
},
computed: {
+ pageSize() {
+ // we want to exponentially increase the page size to reduce the load on the frontend
+ const exponentialSize = (TREE_PAGE_SIZE / TREE_INITIAL_FETCH_COUNT) * (this.fetchCounter + 1);
+ return exponentialSize < TREE_PAGE_SIZE && this.glFeatures.increasePageSizeExponentially
+ ? exponentialSize
+ : TREE_PAGE_SIZE;
+ },
+ totalEntries() {
+ return Object.values(this.entries).flat().length;
+ },
readme() {
return readmeFile(this.entries.blobs);
},
+ pageLimitReached() {
+ return this.totalEntries / this.pagesLoaded >= TREE_PAGE_LIMIT;
+ },
hasShowMore() {
- return !this.clickedShowMore && this.fetchCounter === TREE_INITIAL_FETCH_COUNT;
+ return !this.clickedShowMore && this.pageLimitReached;
},
},
@@ -104,7 +118,7 @@ export default {
if (pageInfo?.hasNextPage) {
this.nextPageCursor = pageInfo.endCursor;
this.fetchCounter += 1;
- if (this.fetchCounter < TREE_INITIAL_FETCH_COUNT || this.clickedShowMore) {
+ if (!this.pageLimitReached || this.clickedShowMore) {
this.fetchFiles();
this.clickedShowMore = false;
}
@@ -127,6 +141,7 @@ export default {
},
handleShowMore() {
this.clickedShowMore = true;
+ this.pagesLoaded += 1;
this.fetchFiles();
},
},
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 7f065dbdf6d..df5a5ea6163 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -17,13 +17,15 @@ import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import {
+ SECONDARY_OPTIONS_TEXT,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+} from '../constants';
const PRIMARY_OPTIONS_TEXT = __('Upload file');
-const SECONDARY_OPTIONS_TEXT = __('Cancel');
const MODAL_TITLE = __('Upload New File');
-const COMMIT_LABEL = __('Commit message');
-const TARGET_BRANCH_LABEL = __('Target branch');
-const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
const REMOVE_FILE_TEXT = __('Remove file');
const NEW_BRANCH_IN_FORK = __(
'A new branch will be created in your fork and a new merge request will be started.',
@@ -170,7 +172,7 @@ export default {
})
.catch(() => {
this.loading = false;
- createFlash(ERROR_MESSAGE);
+ createFlash({ message: ERROR_MESSAGE });
});
},
formData() {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 62d5d3db445..2d2faa8d9f3 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -1,4 +1,10 @@
-const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
+import { __ } from '~/locale';
+export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
+
+export const SECONDARY_OPTIONS_TEXT = __('Cancel');
+export const COMMIT_LABEL = __('Commit message');
+export const TARGET_BRANCH_LABEL = __('Target branch');
+export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index bfd9447d260..a8f263941e2 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -1,6 +1,10 @@
query getBlobInfo($projectPath: ID!, $filePath: String!) {
project(fullPath: $projectPath) {
+ userPermissions {
+ pushCode
+ }
repository {
+ empty
blobs(paths: [$filePath]) {
nodes {
webPath
@@ -15,7 +19,6 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) {
storedExternally
rawPath
replacePath
- canModifyBlob
simpleViewer {
fileType
tooLarge
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
index be6897b9a16..b046fc1f730 100644
--- a/app/assets/javascripts/repository/queries/commit.fragment.graphql
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -5,5 +5,6 @@ fragment TreeEntryCommit on LogTreeCommit {
committedDate
commitPath
fileName
+ filePath
type
}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 6cdd89ad431..36f5e6f4ce1 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -2,8 +2,8 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
-import { fixTitle, hide } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
+import { hide } from '~/tooltips';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
@@ -98,45 +98,15 @@ Sidebar.prototype.toggleTodo = function (e) {
this.todoUpdateDone(data);
})
.catch(() =>
- flash(
- sprintf(__('There was an error %{message} todo.'), {
+ createFlash({
+ message: sprintf(__('There was an error %{message} todo.'), {
message:
ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
}),
- ),
+ }),
);
};
-Sidebar.prototype.todoUpdateDone = function (data) {
- const deletePath = data.delete_path ? data.delete_path : null;
- const attrPrefix = deletePath ? 'mark' : 'todo';
- const $todoBtns = $('.js-issuable-todo');
-
- $(document).trigger('todo:toggle', data.count);
-
- $todoBtns.each((i, el) => {
- const $el = $(el);
- const $elText = $el.find('.js-issuable-todo-inner');
-
- $el
- .removeClass('is-loading')
- .enable()
- .attr('aria-label', $el.data(`${attrPrefix}Text`))
- .attr('title', $el.data(`${attrPrefix}Text`))
- .data('deletePath', deletePath);
-
- if ($el.hasClass('has-tooltip')) {
- fixTitle(el);
- }
-
- if (typeof $el.data('isCollapsed') !== 'undefined') {
- $elText.html($el.data(`${attrPrefix}Icon`));
- } else {
- $elText.text($el.data(`${attrPrefix}Text`));
- }
- });
-};
-
Sidebar.prototype.sidebarCollapseClicked = function (e) {
if ($(e.currentTarget).hasClass('dont-change-state')) {
return;
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index 7f9f796bdee..863f0ab995f 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,9 +1,11 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.graphql';
+import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
+import { captureException } from '~/runner/sentry_utils';
const i18n = {
I18N_EDIT: __('Edit'),
@@ -14,6 +16,7 @@ const i18n = {
};
export default {
+ name: 'RunnerActionsCell',
components: {
GlButton,
GlButtonGroup,
@@ -86,7 +89,7 @@ export default {
});
if (errors && errors.length) {
- this.onError(new Error(errors[0]));
+ throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
@@ -109,7 +112,7 @@ export default {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
- mutation: deleteRunnerMutation,
+ mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
@@ -119,7 +122,7 @@ export default {
refetchQueries: ['getRunners'],
});
if (errors && errors.length) {
- this.onError(new Error(errors[0]));
+ throw new Error(errors.join(' '));
}
} catch (e) {
this.onError(e);
@@ -129,9 +132,13 @@ export default {
},
onError(error) {
- // TODO Render errors when "delete" action is done
- // `active` toggle would not fail due to user input.
- throw error;
+ const { message } = error;
+ createFlash({ message });
+
+ this.reportToSentry(error);
+ },
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
},
},
i18n,
diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
index b3ebdfd82e3..f186a8daf72 100644
--- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
@@ -32,11 +32,11 @@ export default {
<runner-type-badge :type="runnerType" size="sm" />
<gl-badge v-if="locked" variant="warning" size="sm">
- {{ __('locked') }}
+ {{ s__('Runners|locked') }}
</gl-badge>
<gl-badge v-if="paused" variant="danger" size="sm">
- {{ __('paused') }}
+ {{ s__('Runners|paused') }}
</gl-badge>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/helpers/masked_value.vue b/app/assets/javascripts/runner/components/helpers/masked_value.vue
new file mode 100644
index 00000000000..feccb37de81
--- /dev/null
+++ b/app/assets/javascripts/runner/components/helpers/masked_value.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isMasked: true,
+ };
+ },
+ computed: {
+ label() {
+ if (this.isMasked) {
+ return __('Click to reveal');
+ }
+ return __('Click to hide');
+ },
+ icon() {
+ if (this.isMasked) {
+ return 'eye';
+ }
+ return 'eye-slash';
+ },
+ displayedValue() {
+ if (this.isMasked && this.value?.length) {
+ return '*'.repeat(this.value.length);
+ }
+ return this.value;
+ },
+ },
+ methods: {
+ toggleMasked() {
+ this.isMasked = !this.isMasked;
+ },
+ },
+};
+</script>
+<template>
+ <span
+ >{{ displayedValue }}
+ <gl-button
+ :aria-label="label"
+ :icon="icon"
+ class="gl-text-body!"
+ data-testid="toggle-masked"
+ variant="link"
+ @click="toggleMasked"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index bec33ce2f44..e14b3b17fa8 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -1,9 +1,9 @@
<script>
-import { GlFilteredSearchToken } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
-import { __, s__ } from '~/locale';
+import { formatNumber, sprintf, __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
@@ -19,50 +19,9 @@ import {
CONTACTED_ASC,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
+ PARAM_KEY_TAG,
} from '../constants';
-
-const searchTokens = [
- {
- icon: 'status',
- title: __('Status'),
- type: PARAM_KEY_STATUS,
- token: GlFilteredSearchToken,
- // TODO Get more than one value when GraphQL API supports OR for "status"
- unique: true,
- options: [
- { value: STATUS_ACTIVE, title: s__('Runners|Active') },
- { value: STATUS_PAUSED, title: s__('Runners|Paused') },
- { value: STATUS_ONLINE, title: s__('Runners|Online') },
- { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
-
- // Added extra quotes in this title to avoid splitting this value:
- // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
- ],
- // TODO In principle we could support more complex search rules,
- // this can be added to a separate issue.
- operators: OPERATOR_IS_ONLY,
- },
-
- {
- icon: 'file-tree',
- title: __('Type'),
- type: PARAM_KEY_RUNNER_TYPE,
- token: GlFilteredSearchToken,
- // TODO Get more than one value when GraphQL API supports OR for "status"
- unique: true,
- options: [
- { value: INSTANCE_TYPE, title: s__('Runners|shared') },
- { value: GROUP_TYPE, title: s__('Runners|group') },
- { value: PROJECT_TYPE, title: s__('Runners|specific') },
- ],
- // TODO We should support more complex search rules,
- // search for multiple states (OR) or have NOT operators
- operators: OPERATOR_IS_ONLY,
- },
-
- // TODO Support tags
-];
+import TagToken from './search_tokens/tag_token.vue';
const sortOptions = [
{
@@ -95,6 +54,14 @@ export default {
return Array.isArray(val?.filters) && typeof val?.sort === 'string';
},
},
+ namespace: {
+ type: String,
+ required: true,
+ },
+ activeRunnersCount: {
+ type: Number,
+ required: true,
+ },
},
data() {
// filtered_search_bar_root.vue may mutate the inital
@@ -106,6 +73,62 @@ export default {
initialSortBy: sort,
};
},
+ computed: {
+ searchTokens() {
+ return [
+ {
+ icon: 'status',
+ title: __('Status'),
+ type: PARAM_KEY_STATUS,
+ token: BaseToken,
+ unique: true,
+ options: [
+ { value: STATUS_ACTIVE, title: s__('Runners|Active') },
+ { value: STATUS_PAUSED, title: s__('Runners|Paused') },
+ { value: STATUS_ONLINE, title: s__('Runners|Online') },
+ { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
+
+ // Added extra quotes in this title to avoid splitting this value:
+ // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
+ { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
+ ],
+ // TODO In principle we could support more complex search rules,
+ // this can be added to a separate issue.
+ operators: OPERATOR_IS_ONLY,
+ },
+
+ {
+ icon: 'file-tree',
+ title: __('Type'),
+ type: PARAM_KEY_RUNNER_TYPE,
+ token: BaseToken,
+ unique: true,
+ options: [
+ { value: INSTANCE_TYPE, title: s__('Runners|instance') },
+ { value: GROUP_TYPE, title: s__('Runners|group') },
+ { value: PROJECT_TYPE, title: s__('Runners|project') },
+ ],
+ // TODO We should support more complex search rules,
+ // search for multiple states (OR) or have NOT operators
+ operators: OPERATOR_IS_ONLY,
+ },
+
+ {
+ icon: 'tag',
+ title: s__('Runners|Tags'),
+ type: PARAM_KEY_TAG,
+ token: TagToken,
+ recentTokenValuesStorageKey: `${this.namespace}-recent-tags`,
+ operators: OPERATOR_IS_ONLY,
+ },
+ ];
+ },
+ activeRunnersMessage() {
+ return sprintf(__('Runners currently online: %{active_runners_count}'), {
+ active_runners_count: formatNumber(this.activeRunnersCount),
+ });
+ },
+ },
methods: {
onFilter(filters) {
const { sort } = this.value;
@@ -127,19 +150,23 @@ export default {
},
},
sortOptions,
- searchTokens,
};
</script>
<template>
- <filtered-search
- v-bind="$attrs"
- recent-searches-storage-key="runners-search"
- :sort-options="$options.sortOptions"
- :initial-filter-value="initialFilterValue"
- :initial-sort-by="initialSortBy"
- :tokens="$options.searchTokens"
- :search-input-placeholder="__('Search or filter results...')"
- @onFilter="onFilter"
- @onSort="onSort"
- />
+ <div>
+ <filtered-search
+ v-bind="$attrs"
+ :namespace="namespace"
+ recent-searches-storage-key="runners-search"
+ :sort-options="$options.sortOptions"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :tokens="searchTokens"
+ :search-input-placeholder="__('Search or filter results...')"
+ data-testid="runners-filtered-search"
+ @onFilter="onFilter"
+ @onSort="onSort"
+ />
+ <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 41adbbb55f6..69a1f106ca8 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,8 +1,9 @@
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { formatNumber, sprintf, __, s__ } from '~/locale';
+import { formatNumber, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerNameCell from './cells/runner_name_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
@@ -51,19 +52,20 @@ export default {
type: Array,
required: true,
},
- activeRunnersCount: {
- type: Number,
- required: true,
- },
- },
- computed: {
- activeRunnersMessage() {
- return sprintf(__('Runners currently online: %{active_runners_count}'), {
- active_runners_count: formatNumber(this.activeRunnersCount),
- });
- },
},
methods: {
+ formatProjectCount(projectCount) {
+ if (projectCount === null) {
+ return __('n/a');
+ }
+ return formatNumber(projectCount);
+ },
+ formatJobCount(jobCount) {
+ if (jobCount > RUNNER_JOB_COUNT_LIMIT) {
+ return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`;
+ }
+ return formatNumber(jobCount);
+ },
runnerTrAttr(runner) {
if (runner) {
return {
@@ -88,12 +90,12 @@ export default {
</script>
<template>
<div>
- <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
<gl-table
:busy="loading"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
+ data-testid="runner-list"
stacked="md"
fixed
>
@@ -117,12 +119,12 @@ export default {
{{ ipAddress }}
</template>
- <template #cell(projectCount)>
- <!-- TODO add projects count -->
+ <template #cell(projectCount)="{ item: { projectCount } }">
+ {{ formatProjectCount(projectCount) }}
</template>
- <template #cell(jobCount)>
- <!-- TODO add jobs count -->
+ <template #cell(jobCount)="{ item: { jobCount } }">
+ {{ formatJobCount(jobCount) }}
</template>
<template #cell(tagList)="{ item: { tagList } }">
diff --git a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
index 426d377c92b..475d362bb52 100644
--- a/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
+++ b/app/assets/javascripts/runner/components/runner_manual_setup_help.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
+import MaskedValue from '~/runner/components/helpers/masked_value.vue';
import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
@@ -11,6 +12,7 @@ export default {
GlLink,
GlSprintf,
ClipboardButton,
+ MaskedValue,
RunnerInstructions,
RunnerRegistrationTokenReset,
},
@@ -92,7 +94,9 @@ export default {
{{ __('And this registration token:') }}
<br />
- <code data-testid="registration-token">{{ currentRegistrationToken }}</code>
+ <code data-testid="registration-token"
+ ><masked-value :value="currentRegistrationToken"
+ /></code>
<clipboard-button :title="__('Copy token')" :text="currentRegistrationToken" />
</li>
</ol>
diff --git a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
index b03574264d9..2335faa4f85 100644
--- a/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
+++ b/app/assets/javascripts/runner/components/runner_registration_token_reset.vue
@@ -3,9 +3,11 @@ import { GlButton } from '@gitlab/ui';
import createFlash, { FLASH_TYPES } from '~/flash';
import { __, s__ } from '~/locale';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+import { captureException } from '~/runner/sentry_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
export default {
+ name: 'RunnerRegistrationTokenReset',
components: {
GlButton,
},
@@ -52,8 +54,7 @@ export default {
},
});
if (errors && errors.length) {
- this.onError(new Error(errors[0]));
- return;
+ throw new Error(errors.join(' '));
}
this.onSuccess(token);
} catch (e) {
@@ -65,6 +66,8 @@ export default {
onError(error) {
const { message } = error;
createFlash({ message });
+
+ this.reportToSentry(error);
},
onSuccess(token) {
createFlash({
@@ -73,6 +76,9 @@ export default {
});
this.$emit('tokenReset', token);
},
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/runner/components/runner_tag.vue b/app/assets/javascripts/runner/components/runner_tag.vue
new file mode 100644
index 00000000000..06562e618a8
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_tag.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { RUNNER_TAG_BADGE_VARIANT } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ tag: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'md',
+ },
+ },
+ RUNNER_TAG_BADGE_VARIANT,
+};
+</script>
+<template>
+ <gl-badge :size="size" :variant="$options.RUNNER_TAG_BADGE_VARIANT">
+ {{ tag }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index 4ba07e00c96..aec0d8e2c66 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -1,9 +1,9 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import RunnerTag from './runner_tag.vue';
export default {
components: {
- GlBadge,
+ RunnerTag,
},
props: {
tagList: {
@@ -16,18 +16,11 @@ export default {
required: false,
default: 'md',
},
- variant: {
- type: String,
- required: false,
- default: 'info',
- },
},
};
</script>
<template>
<div>
- <gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
- {{ tag }}
- </gl-badge>
+ <runner-tag v-for="tag in tagList" :key="tag" :tag="tag" :size="size" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue
index 927deb290a4..70456b3ab65 100644
--- a/app/assets/javascripts/runner/components/runner_type_help.vue
+++ b/app/assets/javascripts/runner/components/runner_type_help.vue
@@ -44,13 +44,13 @@ export default {
</li>
<li>
<gl-badge variant="warning" size="sm">
- {{ __('locked') }}
+ {{ s__('Runners|locked') }}
</gl-badge>
- {{ __('Cannot be assigned to other projects.') }}
</li>
<li>
<gl-badge variant="danger" size="sm">
- {{ __('paused') }}
+ {{ s__('Runners|paused') }}
</gl-badge>
- {{ __('Not available to run jobs.') }}
</li>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index 0c1b83b6830..85d14547efd 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -7,42 +7,26 @@ import {
GlFormInputGroup,
GlTooltipDirective,
} from '@gitlab/ui';
+import {
+ modelToUpdateMutationVariables,
+ runnerToModel,
+} from 'ee_else_ce/runner/runner_details/runner_update_form_utils';
import createFlash, { FLASH_TYPES } from '~/flash';
import { __ } from '~/locale';
+import { captureException } from '~/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
import runnerUpdateMutation from '../graphql/runner_update.mutation.graphql';
-const runnerToModel = (runner) => {
- const {
- id,
- description,
- maximumTimeout,
- accessLevel,
- active,
- locked,
- runUntagged,
- tagList = [],
- } = runner || {};
-
- return {
- id,
- description,
- maximumTimeout,
- accessLevel,
- active,
- locked,
- runUntagged,
- tagList: tagList.join(', '),
- };
-};
-
export default {
+ name: 'RunnerUpdateForm',
components: {
GlButton,
GlForm,
GlFormCheckbox,
GlFormGroup,
GlFormInputGroup,
+ RunnerUpdateCostFactorFields: () =>
+ import('ee_component/runner/components/runner_update_cost_factor_fields.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -67,18 +51,6 @@ export default {
readonlyIpAddress() {
return this.runner?.ipAddress;
},
- updateMutationInput() {
- const { maximumTimeout, tagList } = this.model;
-
- return {
- ...this.model,
- maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null,
- tagList: tagList
- .split(',')
- .map((tag) => tag.trim())
- .filter((tag) => Boolean(tag)),
- };
- },
},
watch: {
runner(newVal, oldVal) {
@@ -98,31 +70,32 @@ export default {
},
} = await this.$apollo.mutate({
mutation: runnerUpdateMutation,
- variables: {
- input: this.updateMutationInput,
- },
+ variables: modelToUpdateMutationVariables(this.model),
});
if (errors?.length) {
- this.onError(new Error(errors[0]));
+ // Validation errors need not be thrown
+ createFlash({ message: errors[0] });
return;
}
this.onSuccess();
- } catch (e) {
- this.onError(e);
+ } catch (error) {
+ const { message } = error;
+ createFlash({ message });
+
+ this.reportToSentry(error);
} finally {
this.saving = false;
}
},
- onError(error) {
- const { message } = error;
- createFlash({ message });
- },
onSuccess() {
createFlash({ message: __('Changes saved.'), type: FLASH_TYPES.SUCCESS });
this.model = runnerToModel(this.runner);
},
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
},
ACCESS_LEVEL_NOT_PROTECTED,
ACCESS_LEVEL_REF_PROTECTED,
@@ -213,6 +186,8 @@ export default {
<gl-form-input-group v-model="model.tagList" />
</gl-form-group>
+ <runner-update-cost-factor-fields v-model="model" />
+
<div class="form-actions">
<gl-button
type="submit"
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
new file mode 100644
index 00000000000..0c69072f06a
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
+
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { RUNNER_TAG_BG_CLASS } from '../../constants';
+
+export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json';
+
+export default {
+ components: {
+ BaseToken,
+ GlFilteredSearchSuggestion,
+ GlToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tags: [],
+ loading: false,
+ };
+ },
+ methods: {
+ fnCurrentTokenValue(data) {
+ // By default, values are transformed with `toLowerCase`
+ // however, runner tags are case sensitive.
+ return data;
+ },
+ getTagsOptions(search) {
+ // TODO This should be implemented via a GraphQL API
+ // The API should
+ // 1) scope to the rights of the user
+ // 2) stay up to date to the removal of old tags
+ // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
+ return axios
+ .get(TAG_SUGGESTIONS_PATH, {
+ params: {
+ search,
+ },
+ })
+ .then(({ data }) => {
+ return data.map(({ id, name }) => ({ id, value: name, text: name }));
+ });
+ },
+ async fetchTags(searchTerm) {
+ this.loading = true;
+ try {
+ this.tags = await this.getTagsOptions(searchTerm);
+ } catch {
+ createFlash({
+ message: s__('Runners|Something went wrong while fetching the tags suggestions'),
+ });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+ RUNNER_TAG_BG_CLASS,
+};
+</script>
+
+<template>
+ <base-token
+ v-bind="$attrs"
+ :config="config"
+ :suggestions-loading="loading"
+ :suggestions="tags"
+ :fn-current-token-value="fnCurrentTokenValue"
+ :recent-suggestions-storage-key="config.recentTokenValuesStorageKey"
+ @fetch-suggestions="fetchTags"
+ v-on="$listeners"
+ >
+ <template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }">
+ <gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners">
+ {{ activeTokenValue ? activeTokenValue.text : inputValue }}
+ </gl-token>
+ </template>
+ <template #suggestions-list="{ suggestions }">
+ <gl-filtered-search-suggestion v-for="tag in suggestions" :key="tag.id" :value="tag.value">
+ {{ tag.text }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </base-token>
+</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index a57d18ba745..2822882e0cc 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,18 +1,23 @@
import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
+export const RUNNER_JOB_COUNT_LIMIT = 1000;
+export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
-export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
+export const RUNNER_TAG_BADGE_VARIANT = 'info';
+export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// Filtered search parameter names
// - Used for URL params names
// - GlFilteredSearch tokens type
-export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
+export const PARAM_KEY_TAG = 'tag';
+export const PARAM_KEY_SEARCH = 'search';
+
export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_PAGE = 'page';
export const PARAM_KEY_AFTER = 'after';
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
index 84e0d6cc95c..c294cb9bf22 100644
--- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
query getRunner($id: CiRunnerID!) {
runner(id: $id) {
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
index 45df9c625a6..9f837197558 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -6,9 +6,10 @@ query getRunners(
$after: String
$first: Int
$last: Int
- $search: String
$status: CiRunnerStatus
$type: CiRunnerType
+ $tagList: [String!]
+ $search: String
$sort: CiRunnerSort
) {
runners(
@@ -16,9 +17,10 @@ query getRunners(
after: $after
first: $first
last: $last
- search: $search
status: $status
type: $type
+ tagList: $tagList
+ search: $search
sort: $sort
) {
nodes {
diff --git a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql
index d580ea2785e..d580ea2785e 100644
--- a/app/assets/javascripts/runner/graphql/delete_runner.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_delete.mutation.graphql
diff --git a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
index 6d7dc1e2798..2449ee0fc0f 100644
--- a/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_details.fragment.graphql
@@ -1,12 +1,5 @@
+#import "./runner_details_shared.fragment.graphql"
+
fragment RunnerDetails on CiRunner {
- id
- runnerType
- active
- accessLevel
- runUntagged
- locked
- ipAddress
- description
- maximumTimeout
- tagList
+ ...RunnerDetailsShared
}
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
new file mode 100644
index 00000000000..8c50cba7de3
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
@@ -0,0 +1,12 @@
+fragment RunnerDetailsShared on CiRunner {
+ id
+ runnerType
+ active
+ accessLevel
+ runUntagged
+ locked
+ ipAddress
+ description
+ maximumTimeout
+ tagList
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
index 0835e3c7c09..68d6f02f799 100644
--- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql
@@ -10,4 +10,6 @@ fragment RunnerNode on CiRunner {
locked
tagList
contactedAt
+ jobCount
+ projectCount
}
diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
index d50c1880d77..dcc7fdf24f1 100644
--- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/runner_details.fragment.graphql"
+#import "ee_else_ce/runner/graphql/runner_details.fragment.graphql"
mutation runnerUpdate($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
index 5d5fa81b851..6557a7834e7 100644
--- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue
+++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
@@ -1,20 +1,22 @@
<script>
+import createFlash from '~/flash';
+import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { sprintf } from '~/locale';
import RunnerTypeAlert from '../components/runner_type_alert.vue';
import RunnerTypeBadge from '../components/runner_type_badge.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
-import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants';
+import { I18N_DETAILS_TITLE, I18N_FETCH_ERROR } from '../constants';
import getRunnerQuery from '../graphql/get_runner.query.graphql';
+import { captureException } from '../sentry_utils';
export default {
+ name: 'RunnerDetailsApp',
components: {
RunnerTypeAlert,
RunnerTypeBadge,
RunnerUpdateForm,
},
- i18n: {
- I18N_DETAILS_TITLE,
- },
props: {
runnerId: {
type: String,
@@ -31,9 +33,27 @@ export default {
query: getRunnerQuery,
variables() {
return {
- id: convertToGraphQLId(RUNNER_ENTITY_TYPE, this.runnerId),
+ id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
};
},
+ error(error) {
+ createFlash({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ pageTitle() {
+ return sprintf(I18N_DETAILS_TITLE, { runner_id: this.runnerId });
+ },
+ },
+ errorCaptured(error) {
+ this.reportToSentry(error);
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
},
},
};
@@ -41,9 +61,7 @@ export default {
<template>
<div>
<h2 class="page-title">
- {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
-
- <runner-type-badge v-if="runner" :type="runner.runnerType" />
+ {{ pageTitle }} <runner-type-badge v-if="runner" :type="runner.runnerType" />
</h2>
<runner-type-alert v-if="runner" :type="runner.runnerType" />
diff --git a/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js b/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js
new file mode 100644
index 00000000000..3b519fa7d71
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/runner_update_form_utils.js
@@ -0,0 +1,38 @@
+export const runnerToModel = (runner) => {
+ const {
+ id,
+ description,
+ maximumTimeout,
+ accessLevel,
+ active,
+ locked,
+ runUntagged,
+ tagList = [],
+ } = runner || {};
+
+ return {
+ id,
+ description,
+ maximumTimeout,
+ accessLevel,
+ active,
+ locked,
+ runUntagged,
+ tagList: tagList.join(', '),
+ };
+};
+
+export const modelToUpdateMutationVariables = (model) => {
+ const { maximumTimeout, tagList } = model;
+
+ return {
+ input: {
+ ...model,
+ maximumTimeout: maximumTimeout !== '' ? maximumTimeout : null,
+ tagList: tagList
+ ?.split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => Boolean(tag)),
+ },
+ };
+};
diff --git a/app/assets/javascripts/runner/runner_list/index.js b/app/assets/javascripts/runner/runner_list/index.js
index 5eba14a7948..16616f00d1e 100644
--- a/app/assets/javascripts/runner/runner_list/index.js
+++ b/app/assets/javascripts/runner/runner_list/index.js
@@ -12,7 +12,8 @@ export const initRunnerList = (selector = '#js-runner-list') => {
return null;
}
- // TODO `activeRunnersCount` should be implemented using a GraphQL API.
+ // TODO `activeRunnersCount` should be implemented using a GraphQL API
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/333806
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
const apolloProvider = new VueApollo({
diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
index 7f3a980ccca..8d39243d609 100644
--- a/app/assets/javascripts/runner/runner_list/runner_list_app.vue
+++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue
@@ -1,5 +1,5 @@
<script>
-import * as Sentry from '@sentry/browser';
+import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -7,8 +7,9 @@ import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
-import { INSTANCE_TYPE } from '../constants';
+import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
+import { captureException } from '../sentry_utils';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -16,6 +17,7 @@ import {
} from './runner_search_utils';
export default {
+ name: 'RunnerListApp',
components: {
RunnerFilteredSearchBar,
RunnerList,
@@ -59,8 +61,10 @@ export default {
pageInfo: runners?.pageInfo || {},
};
},
- error(err) {
- this.captureException(err);
+ error(error) {
+ createFlash({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
},
},
},
@@ -87,15 +91,12 @@ export default {
},
},
},
- errorCaptured(err) {
- this.captureException(err);
+ errorCaptured(error) {
+ this.reportToSentry(error);
},
methods: {
- captureException(err) {
- Sentry.withScope((scope) => {
- scope.setTag('component', 'runner_list_app');
- Sentry.captureException(err);
- });
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
},
},
INSTANCE_TYPE,
@@ -115,17 +116,17 @@ export default {
</div>
</div>
- <runner-filtered-search-bar v-model="search" namespace="admin_runners" />
+ <runner-filtered-search-bar
+ v-model="search"
+ namespace="admin_runners"
+ :active-runners-count="activeRunnersCount"
+ />
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list
- :runners="runners.items"
- :loading="runnersLoading"
- :active-runners-count="activeRunnersCount"
- />
+ <runner-list :runners="runners.items" :loading="runnersLoading" />
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
diff --git a/app/assets/javascripts/runner/runner_list/runner_search_utils.js b/app/assets/javascripts/runner/runner_list/runner_search_utils.js
index e45972b81db..9a0dc9c3a32 100644
--- a/app/assets/javascripts/runner/runner_list/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_list/runner_search_utils.js
@@ -6,9 +6,10 @@ import {
prepareTokens,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
- PARAM_KEY_SEARCH,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
+ PARAM_KEY_TAG,
+ PARAM_KEY_SEARCH,
PARAM_KEY_SORT,
PARAM_KEY_PAGE,
PARAM_KEY_AFTER,
@@ -40,7 +41,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
return {
filters: prepareTokens(
urlQueryToFilter(query, {
- filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE],
+ filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH,
legacySpacesDecode: false,
}),
@@ -56,15 +57,19 @@ export const fromSearchToUrl = (
) => {
const filterParams = {
// Defaults
- [PARAM_KEY_SEARCH]: null,
[PARAM_KEY_STATUS]: [],
[PARAM_KEY_RUNNER_TYPE]: [],
+ [PARAM_KEY_TAG]: [],
// Current filters
...filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
}),
};
+ if (!filterParams[PARAM_KEY_SEARCH]) {
+ filterParams[PARAM_KEY_SEARCH] = null;
+ }
+
const isDefaultSort = sort !== DEFAULT_SORT;
const isFirstPage = pagination?.page === 1;
const otherParams = {
@@ -87,12 +92,12 @@ export const fromSearchToVariables = ({ filters = [], sort = null, pagination =
variables.search = queryObj[PARAM_KEY_SEARCH];
- // TODO Get more than one value when GraphQL API supports OR for "status"
+ // TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type"
[variables.status] = queryObj[PARAM_KEY_STATUS] || [];
-
- // TODO Get more than one value when GraphQL API supports OR for "runner type"
[variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
+ variables.tagList = queryObj[PARAM_KEY_TAG];
+
if (sort) {
variables.sort = sort;
}
diff --git a/app/assets/javascripts/runner/sentry_utils.js b/app/assets/javascripts/runner/sentry_utils.js
new file mode 100644
index 00000000000..29de1f9adae
--- /dev/null
+++ b/app/assets/javascripts/runner/sentry_utils.js
@@ -0,0 +1,20 @@
+import * as Sentry from '@sentry/browser';
+
+const COMPONENT_TAG = 'vue_component';
+
+/**
+ * Captures an error in a Vue component and sends it
+ * to Sentry
+ *
+ * @param {Object} options
+ * @param {Error} options.error - Exception or error
+ * @param {String} options.component - Component name in CamelCase format
+ */
+export const captureException = ({ error, component }) => {
+ Sentry.withScope((scope) => {
+ if (component) {
+ scope.setTag(COMPONENT_TAG, component);
+ }
+ Sentry.captureException(error);
+ });
+};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 0c3f273fec7..b53557c0ec5 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -2,11 +2,13 @@ import Api from '~/api';
import createFlash from '~/flash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
import * as types from './mutation_types';
+import { loadDataFromLS, setFrequentItemToLS, mergeById } from './utils';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
- Api.groups(search)
+ Api.groups(search, { order_by: 'similarity' })
.then((data) => {
commit(types.RECEIVE_GROUPS_SUCCESS, data);
})
@@ -30,7 +32,12 @@ export const fetchProjects = ({ commit, state }, search) => {
if (groupId) {
// TODO (https://gitlab.com/gitlab-org/gitlab/-/issues/323331): For errors `createFlash` is called twice; in `callback` and in `Api.groupProjects`
- Api.groupProjects(groupId, search, {}, callback);
+ Api.groupProjects(
+ groupId,
+ search,
+ { order_by: 'similarity', with_shared: false, include_subgroups: true },
+ callback,
+ );
} else {
// The .catch() is due to the API method not handling a rejection properly
Api.projects(search, { order_by: 'id' }, callback).catch(() => {
@@ -39,6 +46,40 @@ export const fetchProjects = ({ commit, state }, search) => {
}
};
+export const loadFrequentGroups = async ({ commit }) => {
+ const data = loadDataFromLS(GROUPS_LOCAL_STORAGE_KEY);
+ commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data });
+
+ const promises = data.map((d) => Api.group(d.id));
+ try {
+ const inflatedData = mergeById(await Promise.all(promises), data);
+ commit(types.LOAD_FREQUENT_ITEMS, { key: GROUPS_LOCAL_STORAGE_KEY, data: inflatedData });
+ } catch {
+ createFlash({ message: __('There was a problem fetching recent groups.') });
+ }
+};
+
+export const loadFrequentProjects = async ({ commit }) => {
+ const data = loadDataFromLS(PROJECTS_LOCAL_STORAGE_KEY);
+ commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data });
+
+ const promises = data.map((d) => Api.project(d.id).then((res) => res.data));
+ try {
+ const inflatedData = mergeById(await Promise.all(promises), data);
+ commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: inflatedData });
+ } catch {
+ createFlash({ message: __('There was a problem fetching recent projects.') });
+ }
+};
+
+export const setFrequentGroup = ({ state }, item) => {
+ setFrequentItemToLS(GROUPS_LOCAL_STORAGE_KEY, state.frequentItems, item);
+};
+
+export const setFrequentProject = ({ state }, item) => {
+ setFrequentItemToLS(PROJECTS_LOCAL_STORAGE_KEY, state.frequentItems, item);
+};
+
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
new file mode 100644
index 00000000000..3abf7cac6ba
--- /dev/null
+++ b/app/assets/javascripts/search/store/constants.js
@@ -0,0 +1,7 @@
+export const MAX_FREQUENT_ITEMS = 5;
+
+export const MAX_FREQUENCY = 5;
+
+export const GROUPS_LOCAL_STORAGE_KEY = 'global-search-frequent-groups';
+
+export const PROJECTS_LOCAL_STORAGE_KEY = 'global-search-frequent-projects';
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
new file mode 100644
index 00000000000..650af5fa55a
--- /dev/null
+++ b/app/assets/javascripts/search/store/getters.js
@@ -0,0 +1,9 @@
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+
+export const frequentGroups = (state) => {
+ return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
+};
+
+export const frequentProjects = (state) => {
+ return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY];
+};
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index 1923c8b96ab..4fa88822722 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
+import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
@@ -8,6 +9,7 @@ Vue.use(Vuex);
export const getStoreConfig = ({ query }) => ({
actions,
+ getters,
mutations,
state: createState({ query }),
});
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index a6430b53c4f..5c1c29dc738 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -7,3 +7,5 @@ export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
export const SET_QUERY = 'SET_QUERY';
+
+export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index 91d7cf66c8f..63156a89738 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -26,4 +26,7 @@ export default {
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
+ [types.LOAD_FREQUENT_ITEMS](state, { key, data }) {
+ state.frequentItems[key] = data;
+ },
};
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index 9a0d61d0b93..5b1429ccc97 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,8 +1,14 @@
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+
const createState = ({ query }) => ({
query,
groups: [],
fetchingGroups: false,
projects: [],
fetchingProjects: false,
+ frequentItems: {
+ [GROUPS_LOCAL_STORAGE_KEY]: [],
+ [PROJECTS_LOCAL_STORAGE_KEY]: [],
+ },
});
export default createState;
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
new file mode 100644
index 00000000000..60c09221ca9
--- /dev/null
+++ b/app/assets/javascripts/search/store/utils.js
@@ -0,0 +1,80 @@
+import AccessorUtilities from '../../lib/utils/accessor';
+import { MAX_FREQUENT_ITEMS, MAX_FREQUENCY } from './constants';
+
+function extractKeys(object, keyList) {
+ return Object.fromEntries(keyList.map((key) => [key, object[key]]));
+}
+
+export const loadDataFromLS = (key) => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return [];
+ }
+
+ try {
+ return JSON.parse(localStorage.getItem(key)) || [];
+ } catch {
+ // The LS got in a bad state, let's wipe it
+ localStorage.removeItem(key);
+ return [];
+ }
+};
+
+export const setFrequentItemToLS = (key, data, itemData) => {
+ if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ return;
+ }
+
+ const keyList = [
+ 'id',
+ 'avatar_url',
+ 'name',
+ 'full_name',
+ 'name_with_namespace',
+ 'frequency',
+ 'lastUsed',
+ ];
+
+ try {
+ const frequentItems = data[key].map((obj) => extractKeys(obj, keyList));
+ const item = extractKeys(itemData, keyList);
+ const existingItemIndex = frequentItems.findIndex((i) => i.id === item.id);
+
+ if (existingItemIndex >= 0) {
+ // Up the frequency (Max 5)
+ const currentFrequency = frequentItems[existingItemIndex].frequency;
+ frequentItems[existingItemIndex].frequency = Math.min(currentFrequency + 1, MAX_FREQUENCY);
+ frequentItems[existingItemIndex].lastUsed = new Date().getTime();
+ } else {
+ // Only store a max of 5 items
+ if (frequentItems.length >= MAX_FREQUENT_ITEMS) {
+ frequentItems.pop();
+ }
+
+ frequentItems.push({ ...item, frequency: 1, lastUsed: new Date().getTime() });
+ }
+
+ // Sort by frequency and lastUsed
+ frequentItems.sort((a, b) => {
+ if (a.frequency > b.frequency) {
+ return -1;
+ } else if (a.frequency < b.frequency) {
+ return 1;
+ }
+ return b.lastUsed - a.lastUsed;
+ });
+
+ // Note we do not need to commit a mutation here as immediately after this we refresh the page to
+ // update the search results.
+ localStorage.setItem(key, JSON.stringify(frequentItems));
+ } catch {
+ // The LS got in a bad state, let's wipe it
+ localStorage.removeItem(key);
+ }
+};
+
+export const mergeById = (inflatedData, storedData) => {
+ return inflatedData.map((data) => {
+ const stored = storedData?.find((d) => d.id === data.id) || {};
+ return { ...stored, ...data };
+ });
+};
diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue
index da9252eeacd..45a6ae73fac 100644
--- a/app/assets/javascripts/search/topbar/components/group_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/group_filter.vue
@@ -1,6 +1,6 @@
<script>
import { isEmpty } from 'lodash';
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
import SearchableDropdown from './searchable_dropdown.vue';
@@ -19,13 +19,19 @@ export default {
},
computed: {
...mapState(['groups', 'fetchingGroups']),
+ ...mapGetters(['frequentGroups']),
selectedGroup() {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
},
methods: {
- ...mapActions(['fetchGroups']),
+ ...mapActions(['fetchGroups', 'setFrequentGroup', 'loadFrequentGroups']),
handleGroupChange(group) {
+ // If group.id is null we are clearing the filter and don't need to store that in LS.
+ if (group.id) {
+ this.setFrequentGroup(group);
+ }
+
visitUrl(
setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
);
@@ -44,6 +50,8 @@ export default {
:loading="fetchingGroups"
:selected-item="selectedGroup"
:items="groups"
+ :frequent-items="frequentGroups"
+ @first-open="loadFrequentGroups"
@search="fetchGroups"
@change="handleGroupChange"
/>
diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue
index dbe8ba54216..1ca31db61e5 100644
--- a/app/assets/javascripts/search/topbar/components/project_filter.vue
+++ b/app/assets/javascripts/search/topbar/components/project_filter.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
import SearchableDropdown from './searchable_dropdown.vue';
@@ -18,13 +18,19 @@ export default {
},
computed: {
...mapState(['projects', 'fetchingProjects']),
+ ...mapGetters(['frequentProjects']),
selectedProject() {
return this.initialData ? this.initialData : ANY_OPTION;
},
},
methods: {
- ...mapActions(['fetchProjects']),
+ ...mapActions(['fetchProjects', 'setFrequentProject', 'loadFrequentProjects']),
handleProjectChange(project) {
+ // If project.id is null we are clearing the filter and don't need to store that in LS.
+ if (project.id) {
+ this.setFrequentProject(project);
+ }
+
// This determines if we need to update the group filter or not
const queryParams = {
...(project.namespace?.id && { [GROUP_DATA.queryParam]: project.namespace.id }),
@@ -47,6 +53,8 @@ export default {
:loading="fetchingProjects"
:selected-item="selectedProject"
:items="projects"
+ :frequent-items="frequentProjects"
+ @first-open="loadFrequentProjects"
@search="fetchProjects"
@change="handleProjectChange"
/>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
index 2e2aa052dd8..5653cddda60 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -2,6 +2,7 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
@@ -16,11 +17,13 @@ import SearchableDropdownItem from './searchable_dropdown_item.vue';
export default {
i18n: {
clearLabel: __('Clear'),
+ frequentlySearched: __('Frequently searched'),
},
name: 'SearchableDropdown',
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
@@ -61,17 +64,33 @@ export default {
required: false,
default: () => [],
},
+ frequentItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
searchText: '',
+ hasBeenOpened: false,
};
},
+ computed: {
+ showFrequentItems() {
+ return !this.searchText && this.frequentItems.length > 0;
+ },
+ },
methods: {
isSelected(selected) {
return selected.id === this.selectedItem.id;
},
openDropdown() {
+ if (!this.hasBeenOpened) {
+ this.hasBeenOpened = true;
+ this.$emit('first-open');
+ }
+
this.$emit('search', this.searchText);
},
resetDropdown() {
@@ -99,7 +118,7 @@ export default {
<span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
{{ selectedItem[name] }}
</span>
- <gl-loading-icon v-if="loading" inline class="gl-mr-3" />
+ <gl-loading-icon v-if="loading" size="sm" inline class="gl-mr-3" />
<gl-button
v-if="!isSelected($options.ANY_OPTION)"
v-gl-tooltip
@@ -133,6 +152,25 @@ export default {
<span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span>
</gl-dropdown-item>
</div>
+ <div
+ v-if="showFrequentItems"
+ class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2 gl-mb-2"
+ >
+ <gl-dropdown-section-header>{{
+ $options.i18n.frequentlySearched
+ }}</gl-dropdown-section-header>
+ <searchable-dropdown-item
+ v-for="item in frequentItems"
+ :key="item.id"
+ :item="item"
+ :selected-item="selectedItem"
+ :search-text="searchText"
+ :name="name"
+ :full-name="fullName"
+ data-testid="frequent-items"
+ @change="updateDropdown"
+ />
+ </div>
<div v-if="!loading">
<searchable-dropdown-item
v-for="item in items"
@@ -142,6 +180,7 @@ export default {
:search-text="searchText"
:name="name"
:full-name="fullName"
+ data-testid="searchable-items"
@change="updateDropdown"
/>
</div>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
index 498d4af59b4..42d6444e690 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlAvatar } from '@gitlab/ui';
+import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -9,6 +9,9 @@ export default {
GlDropdownItem,
GlAvatar,
},
+ directives: {
+ SafeHtml,
+ },
props: {
item: {
type: Object,
@@ -62,8 +65,7 @@ export default {
:size="32"
/>
<div class="gl-display-flex gl-flex-direction-column">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span data-testid="item-title" v-html="highlightedItemName">{{ item[name] }}</span>
+ <span v-safe-html="highlightedItemName" data-testid="item-title"></span>
<span class="gl-font-sm gl-text-gray-700" data-testid="item-namespace">{{
truncatedNamespace
}}</span>
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 9c133a79607..4f278677c5f 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -4,16 +4,17 @@ import $ from 'jquery';
import { escape, throttle } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
import Tracking from '~/tracking';
import axios from './lib/utils/axios_utils';
+import { spriteIcon } from './lib/utils/common_utils';
import {
isInGroupsPage,
isInProjectPage,
getGroupSlug,
getProjectSlug,
- spriteIcon,
-} from './lib/utils/common_utils';
+} from './search_autocomplete_utils';
/**
* Search input in top navigation bar.
@@ -343,7 +344,10 @@ export class SearchAutocomplete {
this.searchInput.on('focus', this.onSearchInputFocus);
this.searchInput.on('blur', this.onSearchInputBlur);
this.clearInput.on('click', this.onClearInputClick);
- this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
+ this.dropdownContent.on(
+ 'scroll',
+ throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ );
this.searchInput.on('click', (e) => {
e.stopPropagation();
diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js
new file mode 100644
index 00000000000..a9a0f941e93
--- /dev/null
+++ b/app/assets/javascripts/search_autocomplete_utils.js
@@ -0,0 +1,19 @@
+import { getPagePath } from './lib/utils/common_utils';
+
+export const isInGroupsPage = () => getPagePath() === 'groups';
+
+export const isInProjectPage = () => getPagePath() === 'projects';
+
+export const getProjectSlug = () => {
+ if (isInProjectPage()) {
+ return document?.body?.dataset?.project;
+ }
+ return null;
+};
+
+export const getGroupSlug = () => {
+ if (isInProjectPage() || isInGroupsPage()) {
+ return document?.body?.dataset?.group;
+ }
+ return null;
+};
diff --git a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue
new file mode 100644
index 00000000000..ce6a1b4888b
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ inject: ['autoDevopsHelpPagePath', 'autoDevopsPath'],
+ i18n: {
+ primaryButtonText: s__('SecurityConfiguration|Enable Auto DevOps'),
+ body: s__(
+ 'SecurityConfiguration|Quickly enable all continuous testing and compliance tools by enabling %{linkStart}Auto DevOps%{linkEnd}',
+ ),
+ },
+ methods: {
+ dismissMethod() {
+ this.$emit('dismiss');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ variant="info"
+ :primary-button-link="autoDevopsPath"
+ :primary-button-text="$options.i18n.primaryButtonText"
+ @dismiss="dismissMethod"
+ >
+ <gl-sprintf :message="$options.i18n.body">
+ <template #link="{ content }">
+ <gl-link :href="autoDevopsHelpPagePath">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue
index 2110af1522b..7f250bf1365 100644
--- a/app/assets/javascripts/security_configuration/components/configuration_table.vue
+++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue
@@ -8,6 +8,7 @@ import {
REPORT_TYPE_DAST_PROFILES,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
+ REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
@@ -46,6 +47,7 @@ export default {
[REPORT_TYPE_DAST_PROFILES]: Upgrade,
[REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade,
[REPORT_TYPE_CONTAINER_SCANNING]: Upgrade,
+ [REPORT_TYPE_CLUSTER_IMAGE_SCANNING]: Upgrade,
[REPORT_TYPE_COVERAGE_FUZZING]: Upgrade,
[REPORT_TYPE_API_FUZZING]: Upgrade,
[REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade,
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 142dade914b..5cb9277040d 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -1,7 +1,6 @@
import { helpPagePath } from '~/helpers/help_page_helper';
import { __, s__ } from '~/locale';
-import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
@@ -9,11 +8,15 @@ import {
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
+ REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
+import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
+import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
+
/**
* Translations & helpPagePaths for Static Security Configuration Page
*/
@@ -34,8 +37,8 @@ export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/das
});
export const DAST_PROFILES_NAME = __('DAST Scans');
-export const DAST_PROFILES_DESCRIPTION = __(
- 'Saved scan settings and target site settings which are reusable.',
+export const DAST_PROFILES_DESCRIPTION = s__(
+ 'SecurityConfiguration|Manage profiles for use by DAST scans.',
);
export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage scans');
@@ -76,6 +79,18 @@ export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath(
{ anchor: 'configuration' },
);
+export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning');
+export const CLUSTER_IMAGE_SCANNING_DESCRIPTION = __(
+ 'Check your Kubernetes cluster images for known vulnerabilities.',
+);
+export const CLUSTER_IMAGE_SCANNING_HELP_PATH = helpPagePath(
+ 'user/application_security/cluster_image_scanning/index',
+);
+export const CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH = helpPagePath(
+ 'user/application_security/cluster_image_scanning/index',
+ { anchor: 'configuration' },
+);
+
export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing');
export const COVERAGE_FUZZING_DESCRIPTION = __(
'Find bugs in your code with coverage-guided fuzzing.',
@@ -132,6 +147,12 @@ export const scanners = [
type: REPORT_TYPE_CONTAINER_SCANNING,
},
{
+ name: CLUSTER_IMAGE_SCANNING_NAME,
+ description: CLUSTER_IMAGE_SCANNING_DESCRIPTION,
+ helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH,
+ type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
+ },
+ {
name: SECRET_DETECTION_NAME,
description: SECRET_DETECTION_DESCRIPTION,
helpPath: SECRET_DETECTION_HELP_PATH,
@@ -195,6 +216,10 @@ export const securityFeatures = [
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
type: REPORT_TYPE_DEPENDENCY_SCANNING,
+
+ // This field will eventually come from the backend, the progress is
+ // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
+ canEnableByMergeRequest: window.gon.features?.secDependencyScanningUiEnable,
},
{
name: CONTAINER_SCANNING_NAME,
@@ -204,12 +229,28 @@ export const securityFeatures = [
type: REPORT_TYPE_CONTAINER_SCANNING,
},
{
+ name: CLUSTER_IMAGE_SCANNING_NAME,
+ description: CLUSTER_IMAGE_SCANNING_DESCRIPTION,
+ helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH,
+ configurationHelpPath: CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
+ },
+ {
name: SECRET_DETECTION_NAME,
description: SECRET_DETECTION_DESCRIPTION,
helpPath: SECRET_DETECTION_HELP_PATH,
configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH,
type: REPORT_TYPE_SECRET_DETECTION,
+
+ // This field is currently hardcoded because Secret Detection is always
+ // available. It will eventually come from the Backend, the progress is
+ // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113
available: true,
+
+ // This field is currently hardcoded because SAST can always be enabled via MR
+ // It will eventually come from the Backend, the progress is tracked in
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/331621
+ canEnableByMergeRequest: true,
},
{
name: API_FUZZING_NAME,
@@ -247,4 +288,15 @@ export const featureToMutationMap = {
},
}),
},
+ [REPORT_TYPE_SECRET_DETECTION]: {
+ mutationId: 'configureSecretDetection',
+ getMutationPayload: (projectPath) => ({
+ mutation: configureSecretDetectionMutation,
+ variables: {
+ input: {
+ projectPath,
+ },
+ },
+ }),
+ },
};
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 518a6ede3de..23cffde1f83 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -46,8 +46,7 @@ export default {
return button;
},
showManageViaMr() {
- const { available, configured, canEnableByMergeRequest } = this.feature;
- return canEnableByMergeRequest && available && !configured;
+ return ManageViaMr.canRender(this.feature);
},
cardClasses() {
return { 'gl-bg-gray-10': !this.available };
diff --git a/app/assets/javascripts/security_configuration/components/redesigned_app.vue b/app/assets/javascripts/security_configuration/components/redesigned_app.vue
index d8a12f4a792..915da378a4f 100644
--- a/app/assets/javascripts/security_configuration/components/redesigned_app.vue
+++ b/app/assets/javascripts/security_configuration/components/redesigned_app.vue
@@ -2,18 +2,22 @@
import { GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import AutoDevOpsAlert from './auto_dev_ops_alert.vue';
import FeatureCard from './feature_card.vue';
import SectionLayout from './section_layout.vue';
import UpgradeBanner from './upgrade_banner.vue';
export const i18n = {
compliance: s__('SecurityConfiguration|Compliance'),
+ configurationHistory: s__('SecurityConfiguration|Configuration history'),
securityTesting: s__('SecurityConfiguration|Security testing'),
- securityTestingDescription: s__(
+ latestPipelineDescription: s__(
`SecurityConfiguration|The status of the tools only applies to the
- default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.
- Once you've enabled a scan for the default branch, any subsequent feature
- branch you create will include the scan.`,
+ default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`,
+ ),
+ description: s__(
+ `SecurityConfiguration|Once you've enabled a scan for the default branch,
+ any subsequent feature branch you create will include the scan.`,
),
securityConfiguration: __('Security Configuration'),
};
@@ -28,6 +32,7 @@ export default {
FeatureCard,
SectionLayout,
UpgradeBanner,
+ AutoDevOpsAlert,
UserCalloutDismisser,
},
props: {
@@ -44,6 +49,16 @@ export default {
required: false,
default: false,
},
+ autoDevopsEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canEnableAutoDevops: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
gitlabCiHistoryPath: {
type: String,
required: false,
@@ -64,16 +79,26 @@ export default {
canViewCiHistory() {
return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath);
},
+ shouldShowDevopsAlert() {
+ return !this.autoDevopsEnabled && !this.gitlabCiPresent && this.canEnableAutoDevops;
+ },
},
};
</script>
<template>
<article>
+ <user-callout-dismisser
+ v-if="shouldShowDevopsAlert"
+ feature-name="security_configuration_devops_alert"
+ >
+ <template #default="{ dismiss, shouldShowCallout }">
+ <auto-dev-ops-alert v-if="shouldShowCallout" class="gl-mt-3" @dismiss="dismiss" />
+ </template>
+ </user-callout-dismisser>
<header>
<h1 class="gl-font-size-h1">{{ $options.i18n.securityConfiguration }}</h1>
</header>
-
<user-callout-dismisser v-if="canUpgrade" feature-name="security_configuration_upgrade_banner">
<template #default="{ dismiss, shouldShowCallout }">
<upgrade-banner v-if="shouldShowCallout" @close="dismiss" />
@@ -84,16 +109,19 @@ export default {
<gl-tab data-testid="security-testing-tab" :title="$options.i18n.securityTesting">
<section-layout :heading="$options.i18n.securityTesting">
<template #description>
- <p
- v-if="latestPipelinePath"
- data-testid="latest-pipeline-info-security"
- class="gl-line-height-20"
- >
- <gl-sprintf :message="$options.i18n.securityTestingDescription">
- <template #link="{ content }">
- <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <p>
+ <span data-testid="latest-pipeline-info-security">
+ <gl-sprintf
+ v-if="latestPipelinePath"
+ :message="$options.i18n.latestPipelineDescription"
+ >
+ <template #link="{ content }">
+ <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+
+ {{ $options.i18n.description }}
</p>
<p v-if="canViewCiHistory">
<gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{
@@ -106,6 +134,7 @@ export default {
<feature-card
v-for="feature in augmentedSecurityFeatures"
:key="feature.type"
+ data-testid="security-testing-card"
:feature="feature"
class="gl-mb-6"
/>
@@ -115,16 +144,19 @@ export default {
<gl-tab data-testid="compliance-testing-tab" :title="$options.i18n.compliance">
<section-layout :heading="$options.i18n.compliance">
<template #description>
- <p
- v-if="latestPipelinePath"
- class="gl-line-height-20"
- data-testid="latest-pipeline-info-compliance"
- >
- <gl-sprintf :message="$options.i18n.securityTestingDescription">
- <template #link="{ content }">
- <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <p>
+ <span data-testid="latest-pipeline-info-compliance">
+ <gl-sprintf
+ v-if="latestPipelinePath"
+ :message="$options.i18n.latestPipelineDescription"
+ >
+ <template #link="{ content }">
+ <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+
+ {{ $options.i18n.description }}
</p>
<p v-if="canViewCiHistory">
<gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{
diff --git a/app/assets/javascripts/security_configuration/components/section_layout.vue b/app/assets/javascripts/security_configuration/components/section_layout.vue
index 1e1f83a6d99..e351f9b9d8d 100644
--- a/app/assets/javascripts/security_configuration/components/section_layout.vue
+++ b/app/assets/javascripts/security_configuration/components/section_layout.vue
@@ -11,12 +11,12 @@ export default {
</script>
<template>
- <div class="row">
- <div class="col-lg-5">
+ <div class="row gl-line-height-20">
+ <div class="col-lg-4">
<h2 class="gl-font-size-h2 gl-mt-0">{{ heading }}</h2>
<slot name="description"></slot>
</div>
- <div class="col-lg-7">
+ <div class="col-lg-8">
<slot name="features"></slot>
</div>
</div>
diff --git a/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql
new file mode 100644
index 00000000000..e42a8de64f3
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/graphql/configure_secret_detection.mutation.graphql
@@ -0,0 +1,6 @@
+mutation configureSecretDetection($input: ConfigureSecretDetectionInput!) {
+ configureSecretDetection(input: $input) {
+ successPath
+ errors
+ }
+}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index e1dc6f24737..f05bd79258e 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -7,11 +7,7 @@ import { securityFeatures, complianceFeatures } from './components/constants';
import RedesignedSecurityConfigurationApp from './components/redesigned_app.vue';
import { augmentFeatures } from './utils';
-export const initStaticSecurityConfiguration = (el) => {
- if (!el) {
- return null;
- }
-
+export const initRedesignedSecurityConfiguration = (el) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
@@ -24,35 +20,60 @@ export const initStaticSecurityConfiguration = (el) => {
features,
latestPipelinePath,
gitlabCiHistoryPath,
+ autoDevopsHelpPagePath,
+ autoDevopsPath,
} = el.dataset;
- if (gon.features.securityConfigurationRedesign) {
- const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures(
- securityFeatures,
- complianceFeatures,
- features ? JSON.parse(features) : [],
- );
+ const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures(
+ securityFeatures,
+ complianceFeatures,
+ features ? JSON.parse(features) : [],
+ );
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ projectPath,
+ upgradePath,
+ autoDevopsHelpPagePath,
+ autoDevopsPath,
+ },
+ render(createElement) {
+ return createElement(RedesignedSecurityConfigurationApp, {
+ props: {
+ augmentedComplianceFeatures,
+ augmentedSecurityFeatures,
+ latestPipelinePath,
+ gitlabCiHistoryPath,
+ ...parseBooleanDataAttributes(el, [
+ 'gitlabCiPresent',
+ 'autoDevopsEnabled',
+ 'canEnableAutoDevops',
+ ]),
+ },
+ });
+ },
+ });
+};
+
+export const initCESecurityConfiguration = (el) => {
+ if (!el) {
+ return null;
+ }
- return new Vue({
- el,
- apolloProvider,
- provide: {
- projectPath,
- upgradePath,
- },
- render(createElement) {
- return createElement(RedesignedSecurityConfigurationApp, {
- props: {
- augmentedComplianceFeatures,
- augmentedSecurityFeatures,
- latestPipelinePath,
- gitlabCiHistoryPath,
- ...parseBooleanDataAttributes(el, ['gitlabCiPresent']),
- },
- });
- },
- });
+ if (gon.features?.securityConfigurationRedesign) {
+ return initRedesignedSecurityConfiguration(el);
}
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const { projectPath, upgradePath } = el.dataset;
+
return new Vue({
el,
apolloProvider,
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index 071ebff4f21..ec6b93c6193 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -1,6 +1,8 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
- acc[feature.type] = feature;
+ acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
return acc;
}, {});
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index c608c71714b..4c1f0d892af 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -82,7 +82,7 @@ export default {
text: this.alertContent.actionText,
onClick: (_, toastObject) => {
this[this.alertContent.actionName]();
- toastObject.goAway(0);
+ toastObject.hide();
},
},
};
diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js
index a875ef84088..176745b4177 100644
--- a/app/assets/javascripts/sentry/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -14,6 +14,7 @@ const index = function index() {
release: gon.revision,
tags: {
revision: gon.revision,
+ feature_category: gon.feature_category,
},
});
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index bc3b2f16a6a..a3a2c794a67 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -59,16 +59,18 @@ const SentryConfig = {
configure() {
const { dsn, release, tags, whitelistUrls, environment } = this.options;
+
Sentry.init({
dsn,
release,
- tags,
whitelistUrls,
environment,
ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144
blacklistUrls: this.BLACKLIST_URLS,
sampleRate: SAMPLE_RATE,
});
+
+ Sentry.setTags(tags);
},
setUser() {
diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/service_ping_consent.js
index 3876aa62b75..f145a1b30db 100644
--- a/app/assets/javascripts/usage_ping_consent.js
+++ b/app/assets/javascripts/service_ping_consent.js
@@ -1,23 +1,24 @@
import $ from 'jquery';
-import { deprecatedCreateFlash as Flash, hideFlash } from './flash';
+import createFlash, { hideFlash } from './flash';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
export default () => {
- $('body').on('click', '.js-usage-consent-action', (e) => {
+ $('body').on('click', '.js-service-ping-consent-action', (e) => {
e.preventDefault();
e.stopImmediatePropagation(); // overwrite rails listener
- const { url, checkEnabled, pingEnabled } = e.target.dataset;
+ const { url, checkEnabled, servicePingEnabled } = e.target.dataset;
const data = {
application_setting: {
version_check_enabled: parseBoolean(checkEnabled),
- usage_ping_enabled: parseBoolean(pingEnabled),
+ service_ping_enabled: parseBoolean(servicePingEnabled),
},
};
- const hideConsentMessage = () => hideFlash(document.querySelector('.ping-consent-message'));
+ const hideConsentMessage = () =>
+ hideFlash(document.querySelector('.service-ping-consent-message'));
axios
.put(url, data)
@@ -26,7 +27,9 @@ export default () => {
})
.catch(() => {
hideConsentMessage();
- Flash(__('Something went wrong. Try again later.'));
+ createFlash({
+ message: __('Something went wrong. Try again later.'),
+ });
});
});
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index adb573db652..4b3b22f6db3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -47,7 +47,7 @@ export default {
<template>
<div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900">
{{ assigneeTitle }}
- <gl-loading-icon v-if="loading" inline class="align-bottom" />
+ <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" />
<a
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 9840aa4ed66..c6877226b7d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,6 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
@@ -113,7 +113,9 @@ export default {
})
.catch(() => {
this.loading = false;
- return new Flash(__('Error occurred when saving assignees'));
+ return createFlash({
+ message: __('Error occurred when saving assignees'),
+ });
});
},
exposeAvailabilityStatus(users) {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index d9a974202a3..1dd05d3886e 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -3,6 +3,7 @@ import { GlDropdownItem } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
@@ -80,6 +81,8 @@ export default {
selected: [],
isSettingAssignees: false,
isDirty: false,
+ oldIid: null,
+ oldSelected: null,
};
},
apollo: {
@@ -142,6 +145,14 @@ export default {
return this.currentUser.username !== undefined;
},
},
+ watch: {
+ iid(_, oldIid) {
+ if (this.isDirty) {
+ this.oldIid = oldIid;
+ this.oldSelected = this.selected;
+ }
+ },
+ },
created() {
assigneesWidget.updateAssignees = this.updateAssignees;
},
@@ -157,10 +168,14 @@ export default {
variables: {
...this.queryVariables,
assigneeUsernames,
+ iid: this.oldIid || this.iid,
},
})
.then(({ data }) => {
- this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes);
+ this.$emit('assignees-updated', {
+ id: getIdFromGraphQLId(data.issuableSetAssignees.issuable.id),
+ assignees: data.issuableSetAssignees.issuable.assignees.nodes,
+ });
return data;
})
.catch(() => {
@@ -176,7 +191,10 @@ export default {
saveAssignees() {
if (this.isDirty) {
this.isDirty = false;
- this.updateAssignees(this.selected.map(({ username }) => username));
+ const usernames = this.oldSelected || this.selected;
+ this.updateAssignees(usernames.map(({ username }) => username));
+ this.oldIid = null;
+ this.oldSelected = null;
}
this.$el.dispatchEvent(hideDropdownEvent);
},
diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
index 41b3b6c9a45..bed84dc5706 100644
--- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
@@ -22,8 +22,16 @@ export default {
required: false,
default: '',
},
+ pronouns: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
+ hasPronouns() {
+ return this.pronouns !== null && this.pronouns.trim() !== '';
+ },
isBusy() {
return isUserBusy(this.availability);
},
@@ -32,9 +40,18 @@ export default {
</script>
<template>
<span :class="containerClasses">
- <gl-sprintf v-if="isBusy" :message="s__('UserAvailability|%{author} (Busy)')">
- <template #author>{{ name }}</template>
+ <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')">
+ <template #author
+ >{{ name }}
+ <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ >({{ pronouns }})</span
+ ></template
+ >
+ <template #span="{ content }"
+ ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{
+ content
+ }}</span>
+ </template>
</gl-sprintf>
- <template v-else>{{ name }}</template>
</span>
</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index 372368707af..dc0f2b54a7b 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -4,7 +4,7 @@ import Vue from 'vue';
import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { confidentialityQueries } from '~/sidebar/constants';
+import { confidentialityQueries, Tracking } from '~/sidebar/constants';
import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue';
import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue';
@@ -18,8 +18,8 @@ const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
export default {
tracking: {
- event: 'click_edit_button',
- label: 'right_sidebar',
+ event: Tracking.editEvent,
+ label: Tracking.rightSidebarLabel,
property: 'confidentiality',
},
components: {
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index c3dfa5f8b14..1ff24dec884 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -5,7 +5,13 @@ import { IssuableType } from '~/issue_show/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { dateFields, dateTypes, dueDateQueries, startDateQueries } from '~/sidebar/constants';
+import {
+ dateFields,
+ dateTypes,
+ dueDateQueries,
+ startDateQueries,
+ Tracking,
+} from '~/sidebar/constants';
import SidebarFormattedDate from './sidebar_formatted_date.vue';
import SidebarInheritDate from './sidebar_inherit_date.vue';
@@ -15,8 +21,8 @@ const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
export default {
tracking: {
- event: 'click_edit_button',
- label: 'right_sidebar',
+ event: Tracking.editEvent,
+ label: Tracking.rightSidebarLabel,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -149,6 +155,9 @@ export default {
},
},
methods: {
+ epicDatePopoverEl() {
+ return this.$refs?.epicDatePopover?.$el;
+ },
closeForm() {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
@@ -249,12 +258,7 @@ export default {
:aria-label="$options.i18n.help"
data-testid="inherit-date-popover"
/>
- <gl-popover
- :target="() => $refs.epicDatePopover.$el"
- triggers="focus"
- placement="left"
- boundary="viewport"
- >
+ <gl-popover :target="epicDatePopoverEl" triggers="focus" placement="left" boundary="viewport">
<p>{{ $options.i18n.dateHelpValidMessage }}</p>
<gl-link :href="$options.dateHelpUrl" target="_blank">{{
$options.i18n.learnMore
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index c3f31a3d220..42d2e456a07 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import $ from 'jquery';
import { mapActions } from 'vuex';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __, sprintf } from '../../../locale';
import eventHub from '../../event_hub';
@@ -52,7 +52,9 @@ export default {
const flashMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
);
- Flash(sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }));
+ createFlash({
+ message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }),
+ });
})
.finally(() => {
this.closeForm();
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index e85e416881c..650aa603f18 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -92,11 +92,11 @@ export default {
@click="onClickCollapsedIcon"
>
<gl-icon name="users" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
</div>
<div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2">
- <gl-loading-icon v-if="loading" :inline="true" />
+ <gl-loading-icon v-if="loading" size="sm" :inline="true" />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
index 88c0b18ccc7..295027186cc 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
@@ -35,7 +35,7 @@ export default {
<template>
<div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900">
{{ reviewerTitle }}
- <gl-loading-icon v-if="loading" inline class="align-bottom" />
+ <gl-loading-icon v-if="loading" size="sm" inline class="align-bottom" />
<a
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index c0bd54c60da..e414aaf719b 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -2,7 +2,7 @@
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
@@ -80,7 +80,9 @@ export default {
})
.catch(() => {
this.loading = false;
- return new Flash(__('Error occurred when saving reviewers'));
+ return createFlash({
+ message: __('Error occurred when saving reviewers'),
+ });
});
},
requestReview(data) {
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index 592cfea5e32..fdf63c23552 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -181,7 +181,7 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
- <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" />
<severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" />
</div>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index c80ccc928b3..2e00a23de7c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -16,11 +16,13 @@ import { IssuableType } from '~/issue_show/constants';
import { __, s__, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import {
+ Tracking,
IssuableAttributeState,
IssuableAttributeType,
issuableAttributesQueries,
noAttributeId,
-} from '../constants';
+ defaultEpicSort,
+} from '~/sidebar/constants';
export default {
noAttributeId,
@@ -28,6 +30,7 @@ export default {
issuableAttributesQueries,
i18n: {
[IssuableAttributeType.Milestone]: __('Milestone'),
+ expired: __('(expired)'),
none: __('None'),
},
directives: {
@@ -73,9 +76,14 @@ export default {
type: String,
required: true,
validator(value) {
- return value === IssuableType.Issue;
+ return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
+ icon: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
apollo: {
currentAttribute: {
@@ -117,7 +125,9 @@ export default {
return {
fullPath: this.attrWorkspacePath,
title: this.searchTerm,
+ in: this.searchTerm && this.issuableAttribute === IssuableType.Epic ? 'TITLE' : undefined,
state: this.$options.IssuableAttributeState[this.issuableAttribute],
+ sort: this.issuableAttribute === IssuableType.Epic ? defaultEpicSort : null,
};
},
update(data) {
@@ -140,8 +150,8 @@ export default {
currentAttribute: null,
attributesList: [],
tracking: {
- label: 'right_sidebar',
- event: 'click_edit_button',
+ event: Tracking.editEvent,
+ label: Tracking.rightSidebarLabel,
property: this.issuableAttribute,
},
};
@@ -170,6 +180,9 @@ export default {
attributeTypeTitle() {
return this.$options.i18n[this.issuableAttribute];
},
+ attributeTypeIcon() {
+ return this.icon || this.issuableAttribute;
+ },
i18n() {
return {
noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
@@ -222,7 +235,8 @@ export default {
variables: {
fullPath: this.workspacePath,
attributeId:
- this.issuableAttribute === IssuableAttributeType.Milestone
+ this.issuableAttribute === IssuableAttributeType.Milestone &&
+ this.issuableType === IssuableType.Issue
? getIdFromGraphQLId(attributeId)
: attributeId,
iid: this.iid,
@@ -253,6 +267,11 @@ export default {
attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
);
},
+ isAttributeOverdue(attribute) {
+ return this.issuableAttribute === IssuableAttributeType.Milestone
+ ? attribute?.expired
+ : false;
+ },
showDropdown() {
this.$refs.newDropdown.show();
},
@@ -282,8 +301,10 @@ export default {
>
<template #collapsed>
<div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
- <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
- <span class="collapse-truncated-title">{{ attributeTitle }}</span>
+ <gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
+ <span class="collapse-truncated-title">
+ {{ attributeTitle }}
+ </span>
</div>
<div
:data-testid="`select-${issuableAttribute}`"
@@ -300,8 +321,13 @@ export default {
:attributeUrl="attributeUrl"
:currentAttribute="currentAttribute"
>
- <gl-link class="gl-text-gray-900! gl-font-weight-bold" :href="attributeUrl">
+ <gl-link
+ class="gl-text-gray-900! gl-font-weight-bold"
+ :href="attributeUrl"
+ :data-qa-selector="`${issuableAttribute}_link`"
+ >
{{ attributeTitle }}
+ <span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
</gl-link>
</slot>
</div>
@@ -328,6 +354,7 @@ export default {
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.attributesList.loading"
+ size="sm"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
@@ -351,6 +378,7 @@ export default {
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
+ <span v-if="isAttributeOverdue(attrItem)">{{ $options.i18n.expired }}</span>
</gl-dropdown-item>
</slot>
</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 825d7ff5841..7c496cc422a 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -117,9 +117,15 @@ export default {
{{ title }}
</span>
<slot name="title-extra"></slot>
- <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" />
+ <gl-loading-icon
+ v-if="loading || initialLoading"
+ size="sm"
+ inline
+ class="gl-ml-2 hide-collapsed"
+ />
<gl-loading-icon
v-if="loading && isClassicSidebar"
+ size="sm"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index e97742a1339..bc7e377a966 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -2,17 +2,18 @@
import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
-import { subscribedQueries } from '~/sidebar/constants';
+import { subscribedQueries, Tracking } from '~/sidebar/constants';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
export default {
tracking: {
- event: 'click_edit_button',
- label: 'right_sidebar',
+ event: Tracking.editEvent,
+ label: Tracking.rightSidebarLabel,
property: 'subscriptions',
},
directives: {
@@ -102,7 +103,7 @@ export default {
});
},
isLoggedIn() {
- return Boolean(gon.current_user_id);
+ return isLoggedIn();
},
canSubscribe() {
return this.emailsDisabled || !this.isLoggedIn;
@@ -195,7 +196,7 @@ export default {
class="sidebar-collapsed-icon"
@click="toggleSubscribed"
>
- <gl-loading-icon v-if="isLoading" class="sidebar-item-icon is-active" />
+ <gl-loading-icon v-if="isLoading" size="sm" class="sidebar-item-icon is-active" />
<gl-icon v-else :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" />
</span>
<div v-show="emailsDisabled" class="gl-mt-3 hide-collapsed gl-text-gray-500">
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index f91a78b7f1d..8a14998910b 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import createFlash from '~/flash';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
@@ -52,8 +53,7 @@ export default {
return this.issuableType === 'issue';
},
getGraphQLEntityType() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return this.isIssue() ? 'Issue' : 'MergeRequest';
+ return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
},
extractTimelogs(data) {
const timelogs = data?.issuable?.timelogs?.nodes || [];
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 87ddbbf256a..9a9d03353dc 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -200,7 +200,7 @@ export default {
/>
<div class="hide-collapsed gl-line-height-20 gl-text-gray-900">
{{ __('Time tracking') }}
- <gl-loading-icon v-if="isTimeTrackingInfoLoading" inline />
+ <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" inline />
<div
v-if="!showHelpState"
data-testid="helpButton"
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
new file mode 100644
index 00000000000..a9c4203af22
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -0,0 +1,195 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { produce } from 'immer';
+import createFlash from '~/flash';
+import { __, sprintf } from '~/locale';
+import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
+import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils';
+import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ TodoButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ isClassicSidebar: {
+ default: false,
+ },
+ },
+ props: {
+ issuableId: {
+ type: String,
+ required: true,
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ };
+ },
+ apollo: {
+ todoId: {
+ query() {
+ return todoQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.issuableIid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.currentUserTodos.nodes[0]?.id;
+ },
+ result({ data }) {
+ const currentUserTodos = data.workspace?.issuable?.currentUserTodos?.nodes ?? [];
+ this.todoId = currentUserTodos[0]?.id;
+ this.$emit('todoUpdated', currentUserTodos.length > 0);
+ },
+ error() {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ },
+ },
+ },
+ computed: {
+ todoIdQuery() {
+ return todoQueries[this.issuableType].query;
+ },
+ todoIdQueryVariables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.issuableIid),
+ };
+ },
+ isLoading() {
+ return this.$apollo.queries?.todoId?.loading || this.loading;
+ },
+ hasTodo() {
+ return Boolean(this.todoId);
+ },
+ todoMutationType() {
+ if (this.hasTodo) {
+ return TodoMutationTypes.MarkDone;
+ }
+ return TodoMutationTypes.Create;
+ },
+ collapsedButtonIcon() {
+ return this.hasTodo ? 'todo-done' : 'todo-add';
+ },
+ tootltipTitle() {
+ return todoLabel(this.hasTodo);
+ },
+ },
+ methods: {
+ toggleTodo() {
+ this.loading = true;
+ this.$apollo
+ .mutate({
+ mutation: todoMutations[this.todoMutationType],
+ variables: {
+ input: {
+ targetId: !this.hasTodo ? this.issuableId : undefined,
+ id: this.hasTodo ? this.todoId : undefined,
+ },
+ },
+ update: (
+ store,
+ {
+ data: {
+ todoMutation: { todo },
+ },
+ },
+ ) => {
+ const queryProps = {
+ query: this.todoIdQuery,
+ variables: this.todoIdQueryVariables,
+ };
+
+ const sourceData = store.readQuery(queryProps);
+ const data = produce(sourceData, (draftState) => {
+ draftState.workspace.issuable.currentUserTodos.nodes = this.hasTodo ? [] : [todo];
+ });
+ store.writeQuery({
+ data,
+ ...queryProps,
+ });
+ },
+ })
+ .then(
+ ({
+ data: {
+ todoMutation: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} to-do item.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="sidebar-todo">
+ <todo-button
+ :issuable-type="issuableType"
+ :issuable-id="issuableId"
+ :is-todo="hasTodo"
+ :loading="isLoading"
+ size="small"
+ class="hide-collapsed"
+ @click.stop.prevent="toggleTodo"
+ />
+ <gl-button
+ v-if="isClassicSidebar"
+ category="tertiary"
+ type="reset"
+ class="sidebar-collapsed-icon sidebar-collapsed-container gl-rounded-0! gl-shadow-none!"
+ @click.stop.prevent="toggleTodo"
+ >
+ <gl-icon
+ v-gl-tooltip.left.viewport
+ :title="tootltipTitle"
+ :size="16"
+ :class="{ 'todo-undone': hasTodo }"
+ :name="collapsedButtonIcon"
+ :aria-label="collapsedButtonIcon"
+ />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index f589e7555b3..f7e76cc2b7f 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -85,6 +85,6 @@ export default {
:name="collapsedButtonIcon"
/>
<span v-show="!collapsed" class="issuable-todo-inner">{{ buttonLabel }}</span>
- <gl-loading-icon v-show="isActionActive" :inline="true" />
+ <gl-loading-icon v-show="isActionActive" size="sm" :inline="true" />
</button>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index e8e69c19d9f..08ee4379c0c 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,18 +1,26 @@
import { IssuableType } from '~/issue_show/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
+import epicReferenceQuery from '~/sidebar/queries/epic_reference.query.graphql';
import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql';
import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql';
+import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql';
+import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql';
+import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
+import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql';
+import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql';
+import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql';
import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql';
import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
@@ -20,6 +28,7 @@ import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscr
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
+import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
@@ -35,7 +44,9 @@ import projectIssueMilestoneMutation from './queries/project_issue_milestone.mut
import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql';
import projectMilestonesQuery from './queries/project_milestones.query.graphql';
-export const ASSIGNEES_DEBOUNCE_DELAY = 250;
+export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
+
+export const defaultEpicSort = 'TITLE_ASC';
export const assigneesQueries = {
[IssuableType.Issue]: {
@@ -87,6 +98,9 @@ export const referenceQueries = {
[IssuableType.MergeRequest]: {
query: mergeRequestReferenceQuery,
},
+ [IssuableType.Epic]: {
+ query: epicReferenceQuery,
+ },
};
export const dateTypes = {
@@ -122,6 +136,11 @@ export const subscribedQueries = {
},
};
+export const Tracking = {
+ editEvent: 'click_edit_button',
+ rightSidebarLabel: 'right_sidebar',
+};
+
export const timeTrackingQueries = {
[IssuableType.Issue]: {
query: issueTimeTrackingQuery,
@@ -165,12 +184,19 @@ export const issuableMilestoneQueries = {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
+ [IssuableType.MergeRequest]: {
+ query: mergeRequestMilestone,
+ mutation: mergeRequestMilestoneMutation,
+ },
};
export const milestonesQueries = {
[IssuableType.Issue]: {
query: projectMilestonesQuery,
},
+ [IssuableType.MergeRequest]: {
+ query: projectMilestonesQuery,
+ },
};
export const IssuableAttributeType = {
@@ -187,3 +213,25 @@ export const issuableAttributesQueries = {
list: milestonesQueries,
},
};
+
+export const todoQueries = {
+ [IssuableType.Epic]: {
+ query: epicTodoQuery,
+ },
+ [IssuableType.Issue]: {
+ query: issueTodoQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: mergeRequestTodoQuery,
+ },
+};
+
+export const TodoMutationTypes = {
+ Create: 'create',
+ MarkDone: 'mark-done',
+};
+
+export const todoMutations = {
+ [TodoMutationTypes.Create]: todoCreateMutation,
+ [TodoMutationTypes.MarkDone]: todoMarkDoneMutation,
+};
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 21cd24b0842..5a3122e83d0 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { escape } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import createFlash from '~/flash';
import { __ } from '~/locale';
function isValidProjectId(id) {
@@ -42,8 +43,10 @@ class SidebarMoveIssue {
this.mediator
.fetchAutocompleteProjects(searchTerm)
.then(callback)
- .catch(
- () => new window.Flash(__('An error occurred while fetching projects autocomplete.')),
+ .catch(() =>
+ createFlash({
+ message: __('An error occurred while fetching projects autocomplete.'),
+ }),
);
},
renderRow: (project) => `
@@ -76,7 +79,7 @@ class SidebarMoveIssue {
this.$confirmButton.disable().addClass('is-loading');
this.mediator.moveIssue().catch(() => {
- window.Flash(__('An error occurred while moving the issue.'));
+ createFlash({ message: __('An error occurred while moving the issue.') });
this.$confirmButton.enable().removeClass('is-loading');
});
}
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 67c72b17f1f..dd1b439c482 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -2,6 +2,8 @@ import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issue_show/constants';
@@ -18,6 +20,8 @@ import SidebarConfidentialityWidget from '~/sidebar/components/confidential/side
import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
+import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
+import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import Translate from '../vue_shared/translate';
@@ -29,6 +33,7 @@ import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
+import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
Vue.use(Translate);
@@ -38,6 +43,40 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
+function mountSidebarToDoWidget() {
+ const el = document.querySelector('.js-issuable-todo');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectPath, iid, id } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarTodoWidget,
+ },
+ provide: {
+ isClassicSidebar: true,
+ },
+ render: (createElement) =>
+ createElement('sidebar-todo-widget', {
+ props: {
+ fullPath: projectPath,
+ issuableId:
+ isInIssuePage() || isInDesignPage()
+ ? convertToGraphQLId(TYPE_ISSUE, id)
+ : convertToGraphQLId(TYPE_MERGE_REQUEST, id),
+ issuableIid: iid,
+ issuableType:
+ isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ },
+ }),
+ });
+}
+
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
@@ -154,7 +193,8 @@ function mountReviewersComponent(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- issuableType: isInIssuePage() || isInDesignPage() ? 'issue' : 'merge_request',
+ issuableType:
+ isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
},
}),
});
@@ -166,6 +206,40 @@ function mountReviewersComponent(mediator) {
}
}
+function mountMilestoneSelect() {
+ const el = document.querySelector('.js-milestone-select');
+
+ if (!el) {
+ return false;
+ }
+
+ const { canEdit, projectPath, issueIid } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarDropdownWidget,
+ },
+ provide: {
+ canUpdate: parseBoolean(canEdit),
+ isClassicSidebar: true,
+ },
+ render: (createElement) =>
+ createElement('sidebar-dropdown-widget', {
+ props: {
+ attrWorkspacePath: projectPath,
+ workspacePath: projectPath,
+ iid: issueIid,
+ issuableType:
+ isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ issuableAttribute: IssuableAttributeType.Milestone,
+ icon: 'clock',
+ },
+ }),
+ });
+}
+
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
@@ -460,12 +534,14 @@ export function mountSidebar(mediator) {
initInviteMembersModal();
initInviteMembersTrigger();
+ mountSidebarToDoWidget();
if (isAssigneesWidgetShown) {
mountAssigneesComponent();
} else {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator);
+ mountMilestoneSelect();
mountConfidentialComponent(mediator);
mountDueDateComponent(mediator);
mountReferenceComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
new file mode 100644
index 00000000000..bd10f09aed8
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
@@ -0,0 +1,10 @@
+query epicReference($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ reference(full: true)
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
new file mode 100644
index 00000000000..1e6f9bad5b2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
@@ -0,0 +1,14 @@
+query epicTodos($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ currentUserTodos(state: pending) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
new file mode 100644
index 00000000000..783d36352fe
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
@@ -0,0 +1,14 @@
+query issueTodos($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ currentUserTodos(state: pending) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
new file mode 100644
index 00000000000..5c0edf5acee
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
@@ -0,0 +1,14 @@
+#import "./milestone.fragment.graphql"
+
+query mergeRequestMilestone($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: mergeRequest(iid: $iid) {
+ __typename
+ id
+ attribute: milestone {
+ ...MilestoneFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
new file mode 100644
index 00000000000..93a1c9ea925
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
@@ -0,0 +1,14 @@
+query mergeRequestTodos($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: mergeRequest(iid: $iid) {
+ __typename
+ id
+ currentUserTodos(state: pending) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql
index 8db5359dac0..2ffd58a2da1 100644
--- a/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql
+++ b/app/assets/javascripts/sidebar/queries/milestone.fragment.graphql
@@ -2,4 +2,5 @@ fragment MilestoneFragment on Milestone {
id
title
webUrl: webPath
+ expired
}
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
index d88ad8b1087..721a71bef63 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
@@ -11,6 +11,7 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute
title
id
state
+ expired
}
}
}
diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
index 1237640c468..a3ab1ebc872 100644
--- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
@@ -3,7 +3,13 @@
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
__typename
- attributes: milestones(searchTitle: $title, state: $state) {
+ attributes: milestones(
+ searchTitle: $title
+ state: $state
+ sort: EXPIRED_LAST_DUE_DATE_ASC
+ first: 20
+ includeAncestors: true
+ ) {
nodes {
...MilestoneFragment
state
diff --git a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
new file mode 100644
index 00000000000..4675db9153e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
@@ -0,0 +1,9 @@
+mutation issuableTodoCreate($input: TodoCreateInput!) {
+ todoMutation: todoCreate(input: $input) {
+ __typename
+ todo {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
new file mode 100644
index 00000000000..8253e5e82bc
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
@@ -0,0 +1,9 @@
+mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) {
+ todoMutation: todoMarkDone(input: $input) {
+ __typename
+ todo {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
index b45b6b46c8f..28a47735143 100644
--- a/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/updateStatus.mutation.graphql
@@ -1,6 +1,7 @@
mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) {
updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) {
- issue {
+ issuable: issue {
+ id
healthStatus
}
errors
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
new file mode 100644
index 00000000000..368f06fac7f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
@@ -0,0 +1,17 @@
+mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: ID) {
+ issuableSetAttribute: mergeRequestSetMilestone(
+ input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
+ ) {
+ __typename
+ errors
+ issuable: mergeRequest {
+ __typename
+ id
+ attribute: milestone {
+ title
+ id
+ state
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index 88501f2c305..ace2a163adc 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,4 +1,5 @@
import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
+import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
@@ -88,7 +89,7 @@ export default class SidebarService {
return gqClient.mutate({
mutation: reviewerRereviewMutation,
variables: {
- userId: convertToGraphQLId('User', `${userId}`), // eslint-disable-line @gitlab/require-i18n-strings
+ userId: convertToGraphQLId(TYPE_USER, `${userId}`),
projectPath: this.fullPath,
iid: this.iid.toString(),
},
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 3595354da80..0a5e44a9b95 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,7 +1,7 @@
import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
-import { deprecatedCreateFlash as Flash } from '../flash';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -74,7 +74,11 @@ export default class SidebarMediator {
.then(([restResponse, graphQlResponse]) => {
this.processFetchedData(restResponse.data, graphQlResponse.data);
})
- .catch(() => new Flash(__('Error occurred when fetching sidebar data')));
+ .catch(() =>
+ createFlash({
+ message: __('Error occurred when fetching sidebar data'),
+ }),
+ );
}
processFetchedData(data) {
diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js
index 15d04dadb15..6d77952f24e 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -3,6 +3,35 @@ import $ from 'jquery';
/**
* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
* and controllable by a public API.
+ *
+ * This component has two intervals:
+ *
+ * - current interval - when the page is visible - defined by `startingInterval`, `maxInterval`, and `incrementByFactorOf`
+ * - Example:
+ * - `startingInterval: 10000`, `maxInterval: 240000`, `incrementByFactorOf: 2`
+ * - results in `10s, 20s, 40s, 80s, ..., 240s`, it stops increasing at `240s` and keeps this interval indefinitely.
+ * - hidden interval - when the page is not visible
+ *
+ * Visibility transitions:
+ *
+ * - `visible -> not visible`
+ * - `document.addEventListener('visibilitychange', () => ...)`
+ *
+ * > This event fires with a visibilityState of hidden when a user navigates to a new page, switches tabs, closes the tab, minimizes or closes the browser, or, on mobile, switches from the browser to a different app.
+ *
+ * Source [Document: visibilitychange event - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event)
+ *
+ * - `window.addEventListener('blur', () => ...)` - every time user clicks somewhere else then in the browser page
+ * - `not visible -> visible`
+ * - `document.addEventListener('visibilitychange', () => ...)` same as the transition `visible -> not visible`
+ * - `window.addEventListener('focus', () => ...)`
+ *
+ * The combination of these two listeners can result in an unexpected resumption of polling:
+ *
+ * - switch to a different window (causes `blur`)
+ * - switch to a different desktop (causes `visibilitychange` (not visible))
+ * - switch back to the original desktop (causes `visibilitychange` (visible))
+ * - *now the polling happens even in window that user doesn't work in*
*/
export default class SmartInterval {
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index c53d0575752..f07fb9d926a 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -2,7 +2,7 @@
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { redirectTo, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import {
@@ -135,7 +135,9 @@ export default {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
: SNIPPET_UPDATE_MUTATION_ERROR;
- Flash(sprintf(defaultErrorMsg, { err }));
+ createFlash({
+ message: sprintf(defaultErrorMsg, { err }),
+ });
this.isUpdating = false;
},
getAttachedFiles() {
diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue
index ad1b08a5a07..0fdbc89a038 100644
--- a/app/assets/javascripts/snippets/components/embed_dropdown.vue
+++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue
@@ -60,7 +60,7 @@ export default {
class="gl-dropdown-text-py-0 gl-dropdown-text-block"
data-testid="input"
>
- <gl-form-input-group :value="value" readonly select-on-click :aria-label="name">
+ <gl-form-input-group :value="value" readonly select-on-click :label="name">
<template #append>
<gl-button
v-gl-tooltip.hover
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 612b4c7d2e3..fe169775f96 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -6,13 +6,13 @@ import axios from '~/lib/utils/axios_utils';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
-import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
components: {
BlobHeaderEdit,
GlLoadingIcon,
- EditorLite,
+ SourceEditor,
},
inheritAttrs: false,
props: {
@@ -85,7 +85,7 @@ export default {
size="lg"
class="loading-animation prepend-top-20 gl-mb-6"
/>
- <editor-lite
+ <source-editor
v-else
:value="blob.content"
:file-global-id="blob.id"
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index bf19b63650e..a8f95748e7e 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -274,7 +274,7 @@ export default {
data-qa-selector="delete_snippet_button"
@click="deleteSnippet"
>
- <gl-loading-icon v-if="isDeleting" inline />
+ <gl-loading-icon v-if="isDeleting" size="sm" inline />
{{ __('Delete snippet') }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js
index 43ef5d66422..a4c4cb7f101 100644
--- a/app/assets/javascripts/sortable/sortable_config.js
+++ b/app/assets/javascripts/sortable/sortable_config.js
@@ -4,4 +4,5 @@ export default {
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
+ fallbackTolerance: 1,
};
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index eb3eaa66df5..7cba445d9b1 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils';
import { __, s__ } from './locale';
@@ -28,7 +28,11 @@ export default class Star {
$this.prepend(spriteIcon('star', iconClasses));
}
})
- .catch(() => Flash(__('Star toggle failed. Try again later.')));
+ .catch(() =>
+ createFlash({
+ message: __('Star toggle failed. Try again later.'),
+ }),
+ );
});
}
}
diff --git a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue
index 0685dfdb1d1..781e23cd6c8 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue
@@ -21,7 +21,7 @@ export default {
</script>
<template>
<gl-drawer class="gl-pt-8" :open="isOpen" @close="$emit('close')">
- <template #header>{{ __('Page settings') }}</template>
+ <template #title>{{ __('Page settings') }}</template>
<front-matter-controls :settings="settings" @updateSettings="$emit('updateSettings', $event)" />
</gl-drawer>
</template>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index b08bf26e1dc..ab7fd0542bf 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -28,7 +28,8 @@ export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
-export const USAGE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits';
-export const USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST = 'static_site_editor_merge_requests';
+export const SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits';
+export const SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST =
+ 'static_site_editor_merge_requests';
export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key';
diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js
index 57f32ab4847..4ad2e2618ac 100644
--- a/app/assets/javascripts/static_site_editor/image_repository.js
+++ b/app/assets/javascripts/static_site_editor/image_repository.js
@@ -1,10 +1,13 @@
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import { getBinary } from './services/image_service';
const imageRepository = () => {
const images = new Map();
- const flash = (message) => new Flash(message);
+ const flash = (message) =>
+ createFlash({
+ message,
+ });
const add = (file, url) => {
getBinary(file)
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
index 99bb2080610..5ce2c17f8de 100644
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
@@ -81,11 +81,13 @@ export default {
:invalid-feedback="urlError"
>
<gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
- <gl-sprintf slot="description" :message="description" class="text-gl-muted">
- <template #id>
- <strong>{{ __('0t1DgySidms') }}</strong>
- </template>
- </gl-sprintf>
+ <template #description>
+ <gl-sprintf :message="description" class="text-gl-muted">
+ <template #id>
+ <strong>{{ __('0t1DgySidms') }}</strong>
+ </template>
+ </gl-sprintf>
+ </template>
</gl-form-group>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index ecb7f60a421..99534413d92 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -9,8 +9,8 @@ import {
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
TRACKING_ACTION_CREATE_COMMIT,
TRACKING_ACTION_CREATE_MERGE_REQUEST,
- USAGE_PING_TRACKING_ACTION_CREATE_COMMIT,
- USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
+ SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT,
+ SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE,
DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
} from '../constants';
@@ -58,7 +58,7 @@ const createUpdateSourceFileAction = (sourcePath, content) => [
const commit = (projectId, message, branch, actions) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
- Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_COMMIT);
+ Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT);
return Api.commitMultiple(
projectId,
@@ -74,7 +74,7 @@ const commit = (projectId, message, branch, actions) => {
const createMergeRequest = (projectId, title, description, sourceBranch, targetBranch) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST);
- Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST);
+ Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST);
return Api.createProjectMergeRequest(
projectId,
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 3b2210b9ef2..93353b400e5 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import 'deckar01-task_list';
import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
export default class TaskList {
@@ -22,7 +22,9 @@ export default class TaskList {
errorMessages = e.response.data.errors.join(' ');
}
- return new Flash(errorMessages || __('Update failed'), 'alert');
+ return createFlash({
+ message: errorMessages || __('Update failed'),
+ });
};
this.init();
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index 2577664a5e8..d066834540f 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -137,7 +137,7 @@ export default {
<div v-if="item.loadingLock" class="gl-mx-3">
<p class="gl-display-flex gl-justify-content-start gl-align-items-baseline gl-m-0">
- <gl-loading-icon class="gl-pr-1" />
+ <gl-loading-icon size="sm" class="gl-pr-1" />
{{ loadingLockText(item) }}
</p>
</div>
@@ -146,7 +146,7 @@ export default {
<p
class="gl-display-flex gl-justify-content-start gl-align-items-baseline gl-m-0 gl-text-red-500"
>
- <gl-loading-icon class="gl-pr-1" />
+ <gl-loading-icon size="sm" class="gl-pr-1" />
{{ $options.i18n.removing }}
</p>
</div>
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
index a18f33ebb1f..7eb79120fb8 100644
--- a/app/assets/javascripts/terraform/components/terraform_list.vue
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -98,7 +98,7 @@ export default {
<section>
<gl-tabs>
<gl-tab>
- <template slot="title">
+ <template #title>
<p class="gl-m-0">
{{ s__('Terraform|States') }}
<gl-badge v-if="statesCount">{{ statesCount }}</gl-badge>
diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js
index 03c975d5fe8..5b85107991a 100644
--- a/app/assets/javascripts/toggle_buttons.js
+++ b/app/assets/javascripts/toggle_buttons.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
@@ -42,7 +42,9 @@ function onToggleClicked(toggle, input, clickCallback) {
$(input).trigger('trigger-change');
})
.catch(() => {
- Flash(__('Something went wrong when toggling the button'));
+ createFlash({
+ message: __('Something went wrong when toggling the button'),
+ });
});
}
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
new file mode 100644
index 00000000000..24565c441d8
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -0,0 +1,206 @@
+<script>
+import { GlButton, GlCard, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __, s__ } from '~/locale';
+import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
+import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
+import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
+import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.query.graphql';
+import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql';
+import TokenProjectsTable from './token_projects_table.vue';
+
+export default {
+ i18n: {
+ toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
+ toggleHelpText: s__(
+ `CICD|Select projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable.`,
+ ),
+ cardHeaderTitle: s__('CICD|Add an existing project to the scope'),
+ addProject: __('Add project'),
+ cancel: __('Cancel'),
+ addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
+ projectsFetchError: __('There was a problem fetching the projects'),
+ scopeFetchError: __('There was a problem fetching the job token scope value'),
+ },
+ components: {
+ GlButton,
+ GlCard,
+ GlFormInput,
+ GlLoadingIcon,
+ GlToggle,
+ TokenProjectsTable,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobTokenScopeEnabled: {
+ query: getCIJobTokenScopeQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.project.ciCdSettings.jobTokenScopeEnabled;
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.scopeFetchError });
+ },
+ },
+ projects: {
+ query: getProjectsWithCIJobTokenScopeQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.project?.ciJobTokenScope?.projects?.nodes ?? [];
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.projectsFetchError });
+ },
+ },
+ },
+ data() {
+ return {
+ jobTokenScopeEnabled: null,
+ targetProjectPath: '',
+ projects: [],
+ };
+ },
+ computed: {
+ isProjectPathEmpty() {
+ return this.targetProjectPath === '';
+ },
+ },
+ methods: {
+ async updateCIJobTokenScope() {
+ try {
+ const {
+ data: {
+ ciCdSettingsUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: updateCIJobTokenScopeMutation,
+ variables: {
+ input: {
+ fullPath: this.fullPath,
+ jobTokenScopeEnabled: this.jobTokenScopeEnabled,
+ },
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createFlash({ message: error });
+ } finally {
+ if (this.jobTokenScopeEnabled) {
+ this.getProjects();
+ }
+ }
+ },
+ async addProject() {
+ try {
+ const {
+ data: {
+ ciJobTokenScopeAddProject: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: addProjectCIJobTokenScopeMutation,
+ variables: {
+ input: {
+ projectPath: this.fullPath,
+ targetProjectPath: this.targetProjectPath,
+ },
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createFlash({ message: error });
+ } finally {
+ this.clearTargetProjectPath();
+ this.getProjects();
+ }
+ },
+ async removeProject(removeTargetPath) {
+ try {
+ const {
+ data: {
+ ciJobTokenScopeRemoveProject: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: removeProjectCIJobTokenScopeMutation,
+ variables: {
+ input: {
+ projectPath: this.fullPath,
+ targetProjectPath: removeTargetPath,
+ },
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createFlash({ message: error });
+ } finally {
+ this.getProjects();
+ }
+ },
+ clearTargetProjectPath() {
+ this.targetProjectPath = '';
+ },
+ getProjects() {
+ this.$apollo.queries.projects.refetch();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
+ <template v-else>
+ <gl-toggle
+ v-model="jobTokenScopeEnabled"
+ :label="$options.i18n.toggleLabelTitle"
+ :help="$options.i18n.toggleHelpText"
+ @change="updateCIJobTokenScope"
+ />
+ <div v-if="jobTokenScopeEnabled" data-testid="token-section">
+ <gl-card class="gl-mt-5">
+ <template #header>
+ <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
+ </template>
+ <template #default>
+ <gl-form-input
+ v-model="targetProjectPath"
+ :placeholder="$options.i18n.addProjectPlaceholder"
+ />
+ </template>
+ <template #footer>
+ <gl-button
+ variant="confirm"
+ :disabled="isProjectPathEmpty"
+ data-testid="add-project-button"
+ @click="addProject"
+ >
+ {{ $options.i18n.addProject }}
+ </gl-button>
+ <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
+ </template>
+ </gl-card>
+
+ <token-projects-table :projects="projects" @removeProject="removeProject" />
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
new file mode 100644
index 00000000000..777eda1c4d7
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlButton, GlTable } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+const defaultTableClasses = {
+ thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
+};
+
+export default {
+ i18n: {
+ emptyText: s__('CI/CD|No projects have been added to the scope'),
+ },
+ fields: [
+ {
+ key: 'project',
+ label: __('Projects that can be accessed'),
+ tdClass: 'gl-p-5!',
+ ...defaultTableClasses,
+ columnClass: 'gl-w-85p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-p-5! gl-text-right',
+ ...defaultTableClasses,
+ columnClass: 'gl-w-15p',
+ },
+ ],
+ components: {
+ GlButton,
+ GlTable,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ removeProject(project) {
+ this.$emit('removeProject', project);
+ },
+ },
+};
+</script>
+<template>
+ <gl-table
+ :items="projects"
+ :fields="$options.fields"
+ :tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }"
+ :empty-text="$options.i18n.emptyText"
+ show-empty
+ stacked="sm"
+ fixed
+ >
+ <template #table-colgroup="{ fields }">
+ <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
+ </template>
+
+ <template #cell(project)="{ item }">
+ {{ item.name }}
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <gl-button
+ v-if="item.fullPath !== fullPath"
+ category="primary"
+ variant="danger"
+ icon="remove"
+ :aria-label="__('Remove access')"
+ data-testid="remove-project-button"
+ @click="removeProject(item.fullPath)"
+ />
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..0a7c76dd580
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,5 @@
+mutation addProjectCIJobTokenScope($input: CiJobTokenScopeAddProjectInput!) {
+ ciJobTokenScopeAddProject(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..5107ea30cd1
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,5 @@
+mutation removeProjectCIJobTokenScope($input: CiJobTokenScopeRemoveProjectInput!) {
+ ciJobTokenScopeRemoveProject(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..d99f2e3597d
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
+ ciCdSettingsUpdate(input: $input) {
+ ciCdSettings {
+ jobTokenScopeEnabled
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql
new file mode 100644
index 00000000000..d4f559c3701
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql
@@ -0,0 +1,7 @@
+query getCIJobTokenScope($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ ciCdSettings {
+ jobTokenScopeEnabled
+ }
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
new file mode 100644
index 00000000000..bec0710a1dd
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql
@@ -0,0 +1,12 @@
+query getProjectsWithCIJobTokenScope($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ ciJobTokenScope {
+ projects {
+ nodes {
+ name
+ fullPath
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
new file mode 100644
index 00000000000..6a29883290a
--- /dev/null
+++ b/app/assets/javascripts/token_access/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import TokenAccess from './components/token_access.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
+ const containerEl = document.getElementById(containerId);
+
+ if (!containerEl) {
+ return false;
+ }
+
+ const { fullPath } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ provide: {
+ fullPath,
+ },
+ render(createElement) {
+ return createElement(TokenAccess);
+ },
+ });
+};
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index e0ba7dba97f..3714cac3fba 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -34,6 +34,12 @@ const addExperimentContext = (opts) => {
return options;
};
+const renameKey = (o, oldKey, newKey) => {
+ const ret = {};
+ delete Object.assign(ret, o, { [newKey]: o[oldKey] })[oldKey];
+ return ret;
+};
+
const createEventPayload = (el, { suffix = '' } = {}) => {
const {
trackAction,
@@ -186,15 +192,18 @@ export default class Tracking {
(context) => context.schema !== standardContext.schema,
);
- const mappedConfig = {
- forms: { whitelist: config.forms?.allow || [] },
- fields: { whitelist: config.fields?.allow || [] },
- };
+ const mappedConfig = {};
+ if (config.forms) mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
+ if (config.fields) mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
- if (document.readyState !== 'loading') enabler();
- else document.addEventListener('DOMContentLoaded', enabler);
+ if (document.readyState === 'complete') enabler();
+ else {
+ document.addEventListener('readystatechange', () => {
+ if (document.readyState === 'complete') enabler();
+ });
+ }
}
static mixin(opts = {}) {
diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue
index 80be894c689..0e3c6b396db 100644
--- a/app/assets/javascripts/user_lists/components/user_lists.vue
+++ b/app/assets/javascripts/user_lists/components/user_lists.vue
@@ -3,12 +3,8 @@ import { GlBadge, GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex';
import EmptyState from '~/feature_flags/components/empty_state.vue';
-import {
- buildUrlWithCurrentLocation,
- getParameterByName,
- historyPushState,
-} from '~/lib/utils/common_utils';
-import { objectToQuery } from '~/lib/utils/url_utility';
+import { buildUrlWithCurrentLocation, historyPushState } from '~/lib/utils/common_utils';
+import { objectToQuery, getParameterByName } from '~/lib/utils/url_utility';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import UserListsTable from './user_lists_table.vue';
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 21368edb6af..0e25f71fe05 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -44,6 +44,7 @@ const populateUserInfo = (user) => {
bioHtml: sanitize(userData.bio_html),
workInformation: userData.work_information,
websiteUrl: userData.website_url,
+ pronouns: userData.pronouns,
loaded: true,
});
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
index dc766176617..68f4609f14d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
@@ -14,27 +14,25 @@ export default {
};
</script>
<template>
- <table class="table m-0">
- <thead class="thead-white text-nowrap">
- <tr class="d-none d-sm-table-row">
- <th class="w-0"></th>
- <th>{{ __('Artifact') }}</th>
- <th class="w-50"></th>
- <th>{{ __('Job') }}</th>
- </tr>
- </thead>
+ <div class="gl-pl-7">
+ <table class="table m-0">
+ <thead class="thead-white text-nowrap">
+ <tr class="d-none d-sm-table-row">
+ <th>{{ __('Artifact') }}</th>
+ <th>{{ __('Job') }}</th>
+ </tr>
+ </thead>
- <tbody>
- <tr v-for="item in artifacts" :key="item.text">
- <td class="w-0"></td>
- <td>
- <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link>
- </td>
- <td class="w-0"></td>
- <td>
- <gl-link :href="item.job_path">{{ item.job_name }}</gl-link>
- </td>
- </tr>
- </tbody>
- </table>
+ <tbody>
+ <tr v-for="item in artifacts" :key="item.text">
+ <td>
+ <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link>
+ </td>
+ <td>
+ <gl-link :href="item.job_path">{{ item.job_name }}</gl-link>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
index 410d2740e1d..bb1837399ed 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
@@ -136,7 +136,7 @@ export default {
<template>
<div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
<p v-if="shouldShowLoading" class="usage-info js-usage-info usage-info-loading">
- <gl-loading-icon class="usage-info-load-spinner" />{{
+ <gl-loading-icon size="sm" class="usage-info-load-spinner" />{{
s__('mrWidget|Loading deployment statistics')
}}
</p>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 33809b953ee..0ac98f6c982 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -122,7 +122,7 @@ export default {
</div>
<div v-if="!isCollapsed" class="mr-widget-grouped-section">
<div v-if="isLoadingExpanded" class="report-block-container">
- <gl-loading-icon inline /> {{ __('Loading...') }}
+ <gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
<smart-virtual-list
v-else-if="fullData"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index a619ae9c351..b75f2dce54e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -58,13 +58,13 @@ export default {
<template v-else>
<button
- class="btn-blank btn s32 square gl-mr-3"
+ class="btn-blank btn s32 square"
type="button"
:aria-label="ariaLabel"
:disabled="isLoading"
@click="toggleCollapsed"
>
- <gl-loading-icon v-if="isLoading" />
+ <gl-loading-icon v-if="isLoading" size="sm" />
<gl-icon v-else :name="arrowIconName" class="js-icon" />
</button>
<template v-if="isCollapsed">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index f1230e2fdeb..5e401fc17e9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -1,16 +1,17 @@
<script>
-/* eslint-disable vue/no-v-html */
import {
GlButton,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
+ GlLink,
GlTooltipDirective,
GlModalDirective,
+ GlSafeHtmlDirective as SafeHtml,
+ GlSprintf,
} from '@gitlab/ui';
-import { escape } from 'lodash';
import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
-import { n__, s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue';
@@ -27,10 +28,13 @@ export default {
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
+ GlLink,
+ GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
+ SafeHtml,
},
props: {
mr: {
@@ -42,19 +46,6 @@ export default {
shouldShowCommitsBehindText() {
return this.mr.divergedCommitsCount > 0;
},
- commitsBehindText() {
- return sprintf(
- s__(
- 'mrWidget|The source branch is %{commitsBehindLinkStart}%{commitsBehind}%{commitsBehindLinkEnd} the target branch',
- ),
- {
- commitsBehindLinkStart: `<a href="${escape(this.mr.targetBranchPath)}">`,
- commitsBehind: n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount),
- commitsBehindLinkEnd: '</a>',
- },
- false,
- );
- },
branchNameClipboardData() {
// This supports code in app/assets/javascripts/copy_to_clipboard.js that
// works around ClipboardJS limitations to allow the context-specific
@@ -100,10 +91,10 @@ export default {
<strong>
{{ s__('mrWidget|Request to merge') }}
<tooltip-on-truncate
+ v-safe-html="mr.sourceBranchLink"
:title="mr.sourceBranch"
truncate-target="child"
class="label-branch label-truncate js-source-branch"
- v-html="mr.sourceBranchLink"
/><clipboard-button
data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
@@ -119,11 +110,15 @@ export default {
<a :href="mr.targetBranchTreePath" class="js-target-branch"> {{ mr.targetBranch }} </a>
</tooltip-on-truncate>
</strong>
- <div
- v-if="shouldShowCommitsBehindText"
- class="diverged-commits-count"
- v-html="commitsBehindText"
- ></div>
+ <div v-if="shouldShowCommitsBehindText" class="diverged-commits-count">
+ <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')">
+ <template #link>
+ <gl-link :href="mr.targetBranchPath">{{
+ n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount)
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</div>
<div class="branch-actions d-flex">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 6c162a06161..9bb955c534f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -171,7 +171,7 @@ export default {
<template v-else-if="!hasPipeline">
<gl-loading-icon size="md" />
<p
- class="gl-flex-grow-1 gl-display-flex gl-ml-5 gl-mb-0"
+ class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
>
{{ $options.monitoringPipelineText }}
@@ -190,7 +190,7 @@ export default {
</p>
</template>
<template v-else-if="hasPipeline">
- <a :href="status.details_path" class="align-self-start gl-mr-3">
+ <a :href="status.details_path" class="gl-align-self-center gl-mr-3">
<ci-icon :status="status" :size="24" />
</a>
<div class="ci-widget-container d-flex">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 0cd280c42d2..f99b825ff30 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -2,10 +2,10 @@
import { GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
+import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -109,7 +109,9 @@ export default {
})
.catch(() => {
this.isCancellingAutoMerge = false;
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
});
},
removeSourceBranch() {
@@ -135,7 +137,9 @@ export default {
})
.catch(() => {
this.isRemovingSourceBranch = false;
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
});
},
},
@@ -173,7 +177,7 @@ export default {
data-testid="cancelAutomaticMergeButton"
@click.prevent="cancelAutomaticMerge"
>
- <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" />
+ <gl-loading-icon v-if="isCancellingAutoMerge" size="sm" inline class="gl-mr-1" />
{{ cancelButtonText }}
</a>
</h4>
@@ -196,7 +200,7 @@ export default {
data-testid="removeSourceBranchButton"
@click.prevent="removeSourceBranch"
>
- <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" />
+ <gl-loading-icon v-if="isRemovingSourceBranch" size="sm" inline class="gl-mr-1" />
{{ s__('mrWidget|Delete source branch') }}
</a>
</p>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 5f8630bf7b3..1a764d3d091 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -63,7 +63,7 @@ export default {
size="small"
@click="refreshWidget"
>
- <gl-loading-icon v-if="isRefreshing" :inline="true" />
+ <gl-loading-icon v-if="isRefreshing" size="sm" :inline="true" />
{{ s__('mrWidget|Refresh') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index ee90d734ecb..5a93021978c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -112,7 +112,7 @@ export default {
<div v-else class="media-body space-children gl-display-flex gl-align-items-center">
<span v-if="shouldBeRebased" class="bold">
{{
- s__(`mrWidget|Fast-forward merge is not possible.
+ s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
To merge this request, first rebase locally.`)
}}
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 9da3bea9362..5177eab790b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,14 +1,13 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../../event_hub';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
-import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMerged',
@@ -17,7 +16,7 @@ export default {
},
components: {
MrWidgetAuthorTime,
- statusIcon,
+ GlIcon,
ClipboardButton,
GlLoadingIcon,
GlButton,
@@ -100,7 +99,9 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
});
},
openRevertModal() {
@@ -114,7 +115,7 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon status="success" />
+ <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
<div class="media-body">
<div class="space-children">
<mr-widget-author-time
@@ -129,7 +130,6 @@ export default {
:title="revertTitle"
size="small"
category="secondary"
- variant="warning"
data-qa-selector="revert_button"
@click="openRevertModal"
>
@@ -142,7 +142,6 @@ export default {
:title="revertTitle"
size="small"
category="secondary"
- variant="warning"
data-method="post"
>
{{ revertLabel }}
@@ -167,6 +166,15 @@ export default {
>
{{ cherryPickLabel }}
</gl-button>
+ <gl-button
+ v-if="shouldShowRemoveSourceBranch"
+ :disabled="isMakingRequest"
+ size="small"
+ class="js-remove-branch-button"
+ @click="removeSourceBranch"
+ >
+ {{ s__('mrWidget|Delete source branch') }}
+ </gl-button>
</div>
<section class="mr-info-list" data-qa-selector="merged_status_content">
<p>
@@ -194,19 +202,8 @@ export default {
<p v-if="mr.sourceBranchRemoved">
{{ s__('mrWidget|The source branch has been deleted') }}
</p>
- <p v-if="shouldShowRemoveSourceBranch" class="space-children">
- <span>{{ s__('mrWidget|You can delete the source branch now') }}</span>
- <gl-button
- :disabled="isMakingRequest"
- size="small"
- class="js-remove-branch-button"
- @click="removeSourceBranch"
- >
- {{ s__('mrWidget|Delete source branch') }}
- </gl-button>
- </p>
<p v-if="shouldShowSourceBranchRemoving">
- <gl-loading-icon :inline="true" />
+ <gl-loading-icon size="sm" :inline="true" />
<span> {{ s__('mrWidget|The source branch is being deleted') }} </span>
</p>
</section>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index a82a8a22873..22f41b43095 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -2,9 +2,9 @@
/* eslint-disable vue/no-v-html */
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { escape } from 'lodash';
+import createFlash from '~/flash';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -87,9 +87,7 @@ export default {
},
fastForwardMergeText() {
return sprintf(
- __(
- 'Fast-forward merge is not possible. Rebase the source branch onto %{targetBranch} to allow this merge request to be merged.',
- ),
+ __('Merge blocked: the source branch must be rebased onto the target branch.'),
{
targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
},
@@ -113,7 +111,9 @@ export default {
if (error.response && error.response.data && error.response.data.merge_error) {
this.rebasingError = error.response.data.merge_error;
} else {
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
}
});
},
@@ -129,7 +129,9 @@ export default {
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
}
eventHub.$emit('MRWidgetRebaseSuccess');
@@ -138,7 +140,9 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- Flash(__('Something went wrong. Please try again.'));
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
stopPolling();
});
},
@@ -187,9 +191,7 @@ export default {
data-testid="rebase-message"
data-qa-selector="no_fast_forward_message_content"
>{{
- __(
- 'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
- )
+ __('Merge blocked: the source branch must be rebased onto the target branch.')
}}</span
>
<span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 07de525b1fa..2d0b7fe46a6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -412,7 +412,6 @@ export default {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
- MergeRequest.setStatusBoxToMerged();
MergeRequest.hideCloseButton();
MergeRequest.decreaseCounter();
stopPolling();
@@ -629,11 +628,9 @@ export default {
input-id="squash-message-edit"
squash
>
- <commit-message-dropdown
- slot="header"
- v-model="squashCommitMessage"
- :commits="commits"
- />
+ <template #header>
+ <commit-message-dropdown v-model="squashCommitMessage" :commits="commits" />
+ </template>
</commit-edit>
<commit-edit
v-if="shouldShowMergeEdit"
@@ -641,14 +638,16 @@ export default {
:label="__('Merge commit message')"
input-id="merge-message-edit"
>
- <label slot="checkbox">
- <input
- id="include-description"
- type="checkbox"
- @change="updateMergeCommitMessage($event.target.checked)"
- />
- {{ __('Include merge request description') }}
- </label>
+ <template #checkbox>
+ <label>
+ <input
+ id="include-description"
+ type="checkbox"
+ @change="updateMergeCommitMessage($event.target.checked)"
+ />
+ {{ __('Include merge request description') }}
+ </label>
+ </template>
</commit-edit>
</ul>
</commits-header>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index e9dcf494099..5fe04269e33 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -5,12 +5,12 @@ import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/ap
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
+import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
-import createFlash from '../flash';
import { setFaviconOverlay } from '../lib/utils/favicon';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 4cc2f423d73..8e3160ce2f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,10 +1,11 @@
-import { format } from 'timeago.js';
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { statusBoxState } from '~/issuable/components/status_box.vue';
-import { formatDate } from '../../lib/utils/datetime_utility';
+import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
import { stateKey } from './state_maps';
+const { format } = getTimeago();
+
export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
index b7544a4a5d0..c24318cb9ad 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -204,7 +204,7 @@ export default {
@click="$emit('toggle-sidebar')"
>
<gl-icon name="user" />
- <gl-loading-icon v-if="isUpdating" />
+ <gl-loading-icon v-if="isUpdating" size="sm" />
</div>
<gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
<gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
@@ -270,12 +270,12 @@ export default {
<p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4">
{{ __('No Matching Results') }}
</p>
- <gl-loading-icon v-else />
+ <gl-loading-icon v-else size="sm" />
</div>
</gl-dropdown>
</div>
- <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" />
<div
v-else-if="!isDropdownShowing"
class="hide-collapsed value gl-m-0"
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index ce90a759cee..eaa5fc5af04 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -81,7 +81,7 @@ export default {
<template v-if="sidebarCollapsed">
<div ref="status" class="gl-ml-6" data-testid="status-icon" @click="$emit('toggle-sidebar')">
<gl-icon name="status" />
- <gl-loading-icon v-if="isUpdating" />
+ <gl-loading-icon v-if="isUpdating" size="sm" />
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
<gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
@@ -120,7 +120,7 @@ export default {
@handle-updating="handleUpdating"
/>
- <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" />
<p
v-else-if="!isDropdownShowing"
class="value gl-m-0"
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 13472b48e84..bab13fe7c75 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -68,7 +68,7 @@ export default {
split
@click="handleClick(selectedAction, $event)"
>
- <template slot="button-content">
+ <template #button-content>
<span class="gl-new-dropdown-button-text" v-bind="selectedAction.attrs">
{{ selectedAction.text }}
</span>
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index e6d9a38d1fb..f4c73d12923 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -93,12 +93,12 @@ export default {
return {
name,
list,
- title: this.getAwardListTitle(list),
+ title: this.getAwardListTitle(list, name),
classes: this.getAwardClassBindings(list),
html: glEmojiTag(name),
};
},
- getAwardListTitle(awardsList) {
+ getAwardListTitle(awardsList, name) {
if (!awardsList.length) {
return '';
}
@@ -128,7 +128,7 @@ export default {
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = sprintf(
- __(`%{listToShow}, and %{awardsListLength} more.`),
+ __(`%{listToShow}, and %{awardsListLength} more`),
{
listToShow: namesToShow.join(', '),
awardsListLength: remainingAwardList.length,
@@ -146,7 +146,7 @@ export default {
title = namesToShow.join(__(' and '));
}
- return title;
+ return title + sprintf(__(' reacted with :%{name}:'), { name });
},
handleAward(awardName) {
if (!this.canAwardEmoji) {
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 9c2ed5abf04..0c1d55ae707 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -5,7 +5,13 @@ export default {
props: {
content: {
type: String,
- required: true,
+ required: false,
+ default: null,
+ },
+ richViewer: {
+ type: String,
+ default: '',
+ required: false,
},
type: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index a8a053c0d9e..dc4d1bd56e9 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -18,5 +18,5 @@ export default {
};
</script>
<template>
- <markdown-field-view ref="content" v-safe-html="content" />
+ <markdown-field-view ref="content" v-safe-html="richViewer || content" />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index f6ab3cac536..0589b47edbd 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -9,8 +9,8 @@ export default {
name: 'SimpleViewer',
components: {
GlIcon,
- EditorLite: () =>
- import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'),
+ SourceEditor: () =>
+ import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
},
mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
@@ -53,7 +53,7 @@ export default {
</script>
<template>
<div>
- <editor-lite
+ <source-editor
v-if="isRawContent && refactorBlobViewerEnabled"
:value="content"
:file-name="fileName"
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 4b53f55b856..14e99977a85 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -82,13 +82,7 @@ export default {
data-qa-selector="changed_file_icon_content"
:data-qa-title="tooltipTitle"
>
- <gl-icon
- v-if="showIcon"
- :name="changedIcon"
- :size="size"
- :class="changedIconClass"
- use-deprecated-sizes
- />
+ <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index 2552236a073..fb7105bd416 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -28,18 +28,23 @@ export default {
<slot></slot>
</p>
<resizable-chart-container>
- <gl-area-chart
- slot-scope="{ width }"
- v-bind="$attrs"
- :width="width"
- :height="$options.chartContainerHeight"
- :data="chartData"
- :include-legend-avg-max="false"
- :option="areaChartOptions"
- >
- <slot slot="tooltip-title" name="tooltip-title"></slot>
- <slot slot="tooltip-content" name="tooltip-content"></slot>
- </gl-area-chart>
+ <template #default="{ width }">
+ <gl-area-chart
+ v-bind="$attrs"
+ :width="width"
+ :height="$options.chartContainerHeight"
+ :data="chartData"
+ :include-legend-avg-max="false"
+ :option="areaChartOptions"
+ >
+ <template #tooltip-title>
+ <slot name="tooltip-title"></slot>
+ </template>
+ <template #tooltip-content>
+ <slot name="tooltip-content"></slot>
+ </template>
+ </gl-area-chart>
+ </template>
</resizable-chart-container>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index f4fd57e4cdc..0575d7f6404 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -46,9 +46,12 @@ export default {
:area-chart-options="chartOptions"
>
{{ dateRange }}
-
- <slot slot="tooltip-title" name="tooltip-title"></slot>
- <slot slot="tooltip-content" name="tooltip-content"></slot>
+ <template #tooltip-title>
+ <slot name="tooltip-title"></slot>
+ </template>
+ <template #tooltip-content>
+ <slot name="tooltip-content"></slot>
+ </template>
</ci-cd-analytics-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index dbf459cb289..07bd6019b80 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -64,12 +64,6 @@ export default {
</script>
<template>
<span :class="cssClass">
- <gl-icon
- :name="icon"
- :size="size"
- :class="cssClasses"
- :aria-label="status.icon"
- use-deprecated-sizes
- />
+ <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
index 4bc70870767..733accdff44 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
@@ -3,6 +3,7 @@ import Identicon from '../identicon.vue';
import ProjectAvatarImage from './image.vue';
export default {
+ name: 'DeprecatedProjectAvatar',
components: {
Identicon,
ProjectAvatarImage,
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
index 269736c799c..269736c799c 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index b3edd05b0ee..b786f7752df 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -89,7 +89,7 @@ export default {
<template>
<div class="nothing-here-block">
- <gl-loading-icon v-if="is($options.STATE_LOADING)" />
+ <gl-loading-icon v-if="is($options.STATE_LOADING)" size="sm" />
<template v-else>
<gl-alert
v-show="is($options.STATE_ERRORED)"
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
index 8494f99fd7d..52371e42ba1 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
@@ -1,11 +1,14 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
export default {
+ name: 'DismissibleAlert',
components: {
GlAlert,
},
+ directives: {
+ SafeHtml,
+ },
props: {
html: {
type: String,
@@ -28,6 +31,6 @@ export default {
<template>
<gl-alert v-if="!isDismissed" v-bind="$attrs" @dismiss="dismiss" v-on="$listeners">
- <div v-html="html"></div>
+ <div v-safe-html="html"></div>
</gl-alert>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index a1c7c4dd142..a512eb687b7 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -36,7 +36,7 @@ export default {
data-toggle="dropdown"
aria-expanded="false"
>
- <gl-loading-icon v-show="isLoading" :inline="true" />
+ <gl-loading-icon v-show="isLoading" size="sm" :inline="true" />
<slot v-if="$slots.default"></slot>
<span v-else class="dropdown-toggle-text"> {{ toggleText }} </span>
<gl-icon
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
index 546ee56355f..0b92c947fc7 100644
--- a/app/assets/javascripts/vue_shared/components/expand_button.vue
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -7,7 +7,7 @@ import { __ } from '~/locale';
*
* @example
* <expand-button>
- * <template slot="expanded">
+ * <template #expanded>
* Text goes here.
* </template>
* </expand-button>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index fbadb202d51..b0c1c1531aa 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -103,6 +103,9 @@ export default {
focusedIndex() {
if (!this.mouseOver) {
this.$nextTick(() => {
+ if (!this.$refs.virtualScrollList?.$el) {
+ return;
+ }
const el = this.$refs.virtualScrollList.$el;
const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT;
const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT;
@@ -218,7 +221,7 @@ export default {
</script>
<template>
- <div class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div v-if="visible" class="file-finder-overlay" @mousedown.self="toggle(false)">
<div class="dropdown-menu diff-file-changes file-finder show">
<div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index 4244cab902a..276fb35b51f 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -85,7 +85,7 @@ export default {
</script>
<template>
<span>
- <gl-loading-icon v-if="loading" :inline="true" />
+ <gl-loading-icon v-if="loading" size="sm" :inline="true" />
<gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes />
<svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
@@ -95,7 +95,6 @@ export default {
:name="folderIconName"
:size="size"
class="folder-icon"
- use-deprecated-sizes
data-qa-selector="folder_icon_content"
/>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 9775a9119c6..994ce6a762a 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -10,8 +10,11 @@ export const FILTER_CURRENT = 'Current';
export const OPERATOR_IS = '=';
export const OPERATOR_IS_TEXT = __('is');
export const OPERATOR_IS_NOT = '!=';
+export const OPERATOR_IS_NOT_TEXT = __('is not');
export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
+export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }];
+export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY];
export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index 37436de907f..571d24b50cf 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -215,35 +215,35 @@ export function urlQueryToFilter(query = '', options = {}) {
/**
* Returns array of token values from localStorage
- * based on provided recentTokenValuesStorageKey
+ * based on provided recentSuggestionsStorageKey
*
- * @param {String} recentTokenValuesStorageKey
+ * @param {String} recentSuggestionsStorageKey
* @returns
*/
-export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) {
- let recentlyUsedTokenValues = [];
+export function getRecentlyUsedSuggestions(recentSuggestionsStorageKey) {
+ let recentlyUsedSuggestions = [];
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || [];
+ recentlyUsedSuggestions = JSON.parse(localStorage.getItem(recentSuggestionsStorageKey)) || [];
}
- return recentlyUsedTokenValues;
+ return recentlyUsedSuggestions;
}
/**
* Sets provided token value to recently used array
- * within localStorage for provided recentTokenValuesStorageKey
+ * within localStorage for provided recentSuggestionsStorageKey
*
- * @param {String} recentTokenValuesStorageKey
+ * @param {String} recentSuggestionsStorageKey
* @param {Object} tokenValue
*/
-export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) {
- const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey);
+export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenValue) {
+ const recentlyUsedSuggestions = getRecentlyUsedSuggestions(recentSuggestionsStorageKey);
- recentlyUsedTokenValues.splice(0, 0, { ...tokenValue });
+ recentlyUsedSuggestions.splice(0, 0, { ...tokenValue });
if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(
- recentTokenValuesStorageKey,
- JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
+ recentSuggestionsStorageKey,
+ JSON.stringify(uniqWith(recentlyUsedSuggestions, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
);
}
}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index 3b261f5ac25..a25a19a006c 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -74,13 +74,13 @@ export default {
:config="config"
:value="value"
:active="active"
- :tokens-list-loading="loading"
- :token-values="authors"
+ :suggestions-loading="loading"
+ :suggestions="authors"
:fn-active-token-value="getActiveAuthor"
- :default-token-values="defaultAuthors"
- :preloaded-token-values="preloadedAuthors"
- :recent-token-values-storage-key="config.recentTokenValuesStorageKey"
- @fetch-token-values="fetchAuthorBySearchTerm"
+ :default-suggestions="defaultAuthors"
+ :preloaded-suggestions="preloadedAuthors"
+ :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
+ @fetch-suggestions="fetchAuthorBySearchTerm"
v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
@@ -93,9 +93,9 @@ export default {
/>
<span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span>
</template>
- <template #token-values-list="{ tokenValues }">
+ <template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
- v-for="author in tokenValues"
+ v-for="author in suggestions"
:key="author.username"
:value="author.username"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index bda6b340871..a4804525a53 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -6,9 +6,10 @@ import {
GlDropdownSectionHeader,
GlLoadingIcon,
} from '@gitlab/ui';
+import { debounce } from 'lodash';
import { DEBOUNCE_DELAY } from '../constants';
-import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
+import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
export default {
components: {
@@ -31,12 +32,12 @@ export default {
type: Boolean,
required: true,
},
- tokensListLoading: {
+ suggestionsLoading: {
type: Boolean,
required: false,
default: false,
},
- tokenValues: {
+ suggestions: {
type: Array,
required: false,
default: () => [],
@@ -44,21 +45,21 @@ export default {
fnActiveTokenValue: {
type: Function,
required: false,
- default: (tokenValues, currentTokenValue) => {
- return tokenValues.find(({ value }) => value === currentTokenValue);
+ default: (suggestions, currentTokenValue) => {
+ return suggestions.find(({ value }) => value === currentTokenValue);
},
},
- defaultTokenValues: {
+ defaultSuggestions: {
type: Array,
required: false,
default: () => [],
},
- preloadedTokenValues: {
+ preloadedSuggestions: {
type: Array,
required: false,
default: () => [],
},
- recentTokenValuesStorageKey: {
+ recentSuggestionsStorageKey: {
type: String,
required: false,
default: '',
@@ -77,21 +78,21 @@ export default {
data() {
return {
searchKey: '',
- recentTokenValues: this.recentTokenValuesStorageKey
- ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey)
+ recentSuggestions: this.recentSuggestionsStorageKey
+ ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey)
: [],
loading: false,
};
},
computed: {
- isRecentTokenValuesEnabled() {
- return Boolean(this.recentTokenValuesStorageKey);
+ isRecentSuggestionsEnabled() {
+ return Boolean(this.recentSuggestionsStorageKey);
},
recentTokenIds() {
- return this.recentTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]);
+ return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
preloadedTokenIds() {
- return this.preloadedTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]);
+ return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
currentTokenValue() {
if (this.fnCurrentTokenValue) {
@@ -100,17 +101,17 @@ export default {
return this.value.data.toLowerCase();
},
activeTokenValue() {
- return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
+ return this.fnActiveTokenValue(this.suggestions, this.currentTokenValue);
},
/**
- * Return all the tokenValues when searchKey is present
- * otherwise return only the tokenValues which aren't
+ * Return all the suggestions when searchKey is present
+ * otherwise return only the suggestions which aren't
* present in "Recently used"
*/
- availableTokenValues() {
+ availableSuggestions() {
return this.searchKey
- ? this.tokenValues
- : this.tokenValues.filter(
+ ? this.suggestions
+ : this.suggestions.filter(
(tokenValue) =>
!this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) &&
!this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]),
@@ -121,30 +122,30 @@ export default {
active: {
immediate: true,
handler(newValue) {
- if (!newValue && !this.tokenValues.length) {
- this.$emit('fetch-token-values', this.value.data);
+ if (!newValue && !this.suggestions.length) {
+ this.$emit('fetch-suggestions', this.value.data);
}
},
},
},
methods: {
- handleInput({ data }) {
+ handleInput: debounce(function debouncedSearch({ data }) {
this.searchKey = data;
- setTimeout(() => {
- if (!this.tokensListLoading) this.$emit('fetch-token-values', data);
- }, DEBOUNCE_DELAY);
- },
+ if (!this.suggestionsLoading) {
+ this.$emit('fetch-suggestions', data);
+ }
+ }, DEBOUNCE_DELAY),
handleTokenValueSelected(activeTokenValue) {
// Make sure that;
// 1. Recently used values feature is enabled
// 2. User has actually selected a value
// 3. Selected value is not part of preloaded list.
if (
- this.isRecentTokenValuesEnabled &&
+ this.isRecentSuggestionsEnabled &&
activeTokenValue &&
!this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier])
) {
- setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue);
+ setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue);
}
},
},
@@ -168,9 +169,9 @@ export default {
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
<template #suggestions>
- <template v-if="defaultTokenValues.length">
+ <template v-if="defaultSuggestions.length">
<gl-filtered-search-suggestion
- v-for="token in defaultTokenValues"
+ v-for="token in defaultSuggestions"
:key="token.value"
:value="token.value"
>
@@ -178,19 +179,19 @@ export default {
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
</template>
- <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey">
+ <template v-if="isRecentSuggestionsEnabled && recentSuggestions.length && !searchKey">
<gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header>
- <slot name="token-values-list" :token-values="recentTokenValues"></slot>
+ <slot name="suggestions-list" :suggestions="recentSuggestions"></slot>
<gl-dropdown-divider />
</template>
<slot
- v-if="preloadedTokenValues.length && !searchKey"
- name="token-values-list"
- :token-values="preloadedTokenValues"
+ v-if="preloadedSuggestions.length && !searchKey"
+ name="suggestions-list"
+ :suggestions="preloadedSuggestions"
></slot>
- <gl-loading-icon v-if="tokensListLoading" />
+ <gl-loading-icon v-if="suggestionsLoading" size="sm" />
<template v-else>
- <slot name="token-values-list" :token-values="availableTokenValues"></slot>
+ <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
</template>
</template>
</gl-filtered-search-token>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
index 694dcd95b5e..5859fd10688 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
@@ -97,7 +97,7 @@ export default {
{{ branch.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultBranches.length" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="branch in branches"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 9ba7f3d1a1d..d186f46866c 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -101,7 +101,7 @@ export default {
{{ emoji.value }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEmojis.length" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="emoji in emojis"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index d21fa9a344a..aa234cf86d9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -56,7 +56,7 @@ export default {
}
// Current value is a string.
- const [groupPath, idProperty] = this.currentValue?.split('::&');
+ const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator);
return this.epics.find(
(epic) =>
epic.group_full_path === groupPath &&
@@ -65,6 +65,9 @@ export default {
}
return null;
},
+ displayText() {
+ return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`;
+ },
},
watch: {
active: {
@@ -103,8 +106,10 @@ export default {
this.fetchEpicsBySearchTerm({ epicPath, search: data });
}, DEBOUNCE_DELAY),
- getEpicDisplayText(epic) {
- return `${epic.title}${this.$options.separator}${epic.iid}`;
+ getValue(epic) {
+ return this.config.useIdValue
+ ? String(epic[this.idProperty])
+ : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`;
},
},
};
@@ -118,7 +123,7 @@ export default {
@input="searchEpics"
>
<template #view="{ inputValue }">
- {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
+ {{ activeEpic ? displayText : inputValue }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
@@ -129,13 +134,9 @@ export default {
{{ epic.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEpics.length" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
- <gl-filtered-search-suggestion
- v-for="epic in epics"
- :key="epic.id"
- :value="`${epic.group_full_path}::&${epic[idProperty]}`"
- >
+ <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)">
{{ epic.title }}
</gl-filtered-search-suggestion>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
index 7b6a590279a..ba8b2421726 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
@@ -7,6 +7,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants';
@@ -30,8 +31,7 @@ export default {
data() {
return {
iterations: this.config.initialIterations || [],
- defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS,
- loading: true,
+ loading: false,
};
},
computed: {
@@ -39,7 +39,12 @@ export default {
return this.value.data;
},
activeIteration() {
- return this.iterations.find((iteration) => iteration.title === this.currentValue);
+ return this.iterations.find(
+ (iteration) => getIdFromGraphQLId(iteration.id) === Number(this.currentValue),
+ );
+ },
+ defaultIterations() {
+ return this.config.defaultIterations || DEFAULT_ITERATIONS;
},
},
watch: {
@@ -53,6 +58,9 @@ export default {
},
},
methods: {
+ getValue(iteration) {
+ return String(getIdFromGraphQLId(iteration.id));
+ },
fetchIterationBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
? this.config.fetchIterations(this.config.fetchPath, searchTerm)
@@ -95,12 +103,12 @@ export default {
{{ iteration.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultIterations.length" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="iteration in iterations"
- :key="iteration.title"
- :value="iteration.title"
+ :key="iteration.id"
+ :value="getValue(iteration)"
>
{{ iteration.title }}
</gl-filtered-search-suggestion>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index e496d099a42..4d08f81fee9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -96,12 +96,12 @@ export default {
:config="config"
:value="value"
:active="active"
- :tokens-list-loading="loading"
- :token-values="labels"
+ :suggestions-loading="loading"
+ :suggestions="labels"
:fn-active-token-value="getActiveLabel"
- :default-token-values="defaultLabels"
- :recent-token-values-storage-key="config.recentTokenValuesStorageKey"
- @fetch-token-values="fetchLabelBySearchTerm"
+ :default-suggestions="defaultLabels"
+ :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
+ @fetch-suggestions="fetchLabelBySearchTerm"
v-on="$listeners"
>
<template
@@ -115,9 +115,9 @@ export default {
>~{{ activeTokenValue ? getLabelName(activeTokenValue) : inputValue }}</gl-token
>
</template>
- <template #token-values-list="{ tokenValues }">
+ <template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
- v-for="label in tokenValues"
+ v-for="label in suggestions"
:key="label.id"
:value="getLabelName(label)"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index cda6e4d6726..66ad5ef5b4e 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -9,6 +9,7 @@ import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
+import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
@@ -34,7 +35,7 @@ export default {
return {
milestones: this.config.initialMilestones || [],
defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES,
- loading: true,
+ loading: false,
};
},
computed: {
@@ -59,11 +60,16 @@ export default {
},
methods: {
fetchMilestoneBySearchTerm(searchTerm = '') {
+ if (this.loading) {
+ return;
+ }
+
this.loading = true;
this.config
.fetchMilestones(searchTerm)
- .then(({ data }) => {
- this.milestones = data;
+ .then((response) => {
+ const data = Array.isArray(response) ? response : response.data;
+ this.milestones = data.slice().sort(sortMilestonesByDueDate);
})
.catch(() => createFlash({ message: __('There was a problem fetching milestones.') }))
.finally(() => {
@@ -96,7 +102,7 @@ export default {
{{ milestone.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultMilestones.length" />
- <gl-loading-icon v-if="loading" />
+ <gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="milestone in milestones"
diff --git a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
index 74f988476e3..26c50345c19 100644
--- a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
+++ b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
@@ -1,3 +1,4 @@
+<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
<template functional>
<footer class="form-actions d-flex justify-content-between">
<div><slot name="prepend"></slot></div>
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
index 96d99faa952..dd0c0358ef6 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
@@ -74,6 +74,8 @@ export default {
@hidden="syncHide"
>
<slot></slot>
- <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot>
+ <template #modal-footer>
+ <slot name="modal-footer" :ok="ok" :cancel="cancel"></slot>
+ </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 80b7a9b7d05..9ea48050079 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -4,7 +4,7 @@ import { GlIcon } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
@@ -222,7 +222,11 @@ export default {
axios
.post(this.markdownPreviewPath, { text: this.textareaValue })
.then((response) => this.renderMarkdown(response.data))
- .catch(() => new Flash(__('Error loading markdown preview')));
+ .catch(() =>
+ createFlash({
+ message: __('Error loading markdown preview'),
+ }),
+ );
} else {
this.renderMarkdown();
}
@@ -245,7 +249,11 @@ export default {
this.$nextTick()
.then(() => $(this.$refs['markdown-preview']).renderGFM())
- .catch(() => new Flash(__('Error rendering markdown preview')));
+ .catch(() =>
+ createFlash({
+ message: __('Error rendering markdown preview'),
+ }),
+ );
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 83b8a6ae562..065d9b1b5dd 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import ApplySuggestion from './apply_suggestion.vue';
@@ -73,7 +74,7 @@ export default {
return __('Applying suggestions...');
},
isLoggedIn() {
- return Boolean(gon.current_user_id);
+ return isLoggedIn();
},
},
methods: {
@@ -110,7 +111,7 @@ export default {
</div>
<div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
<div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
- <gl-loading-icon class="d-flex-center mr-2" />
+ <gl-loading-icon size="sm" class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
<div v-else-if="canApply && isBatched" class="d-flex align-items-center">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
index 9059f0d2a8b..a04f8616acb 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -1,7 +1,11 @@
<script>
-/* eslint-disable vue/no-v-html */
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+
export default {
name: 'SuggestionDiffRow',
+ directives: {
+ SafeHtml,
+ },
props: {
line: {
type: Object,
@@ -32,7 +36,7 @@ export default {
:class="[{ 'd-table-cell': displayAsCell }, lineType]"
data-testid="suggestion-diff-content"
>
- <span v-if="line.rich_text" class="line" v-html="line.rich_text"></span>
+ <span v-if="line.rich_text" v-safe-html="line.rich_text" class="line"></span>
<span v-else-if="line.text" class="line">{{ line.text }}</span>
<span v-else class="line"></span>
</td>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 53d1cca7af3..63774c6c498 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -1,7 +1,7 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import Vue from 'vue';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.vue';
@@ -79,7 +79,10 @@ export default {
const suggestionElements = container.querySelectorAll('.js-render-suggestion');
if (this.lineType === 'old') {
- Flash(__('Unable to apply suggestions to a deleted line.'), 'alert', this.$el);
+ createFlash({
+ message: __('Unable to apply suggestions to a deleted line.'),
+ parent: this.$el,
+ });
}
suggestionElements.forEach((suggestionEl, i) => {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 7393a8791b7..7112295fa57 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -82,7 +82,7 @@ export default {
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
- <gl-loading-icon inline />
+ <gl-loading-icon size="sm" inline />
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 69afd711797..d6501a37a35 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -16,12 +16,15 @@
* :note="{body: 'This is a note'}"
* />
*/
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { renderMarkdown } from '~/notes/utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import userAvatarLink from '../user_avatar/user_avatar_link.vue';
export default {
name: 'PlaceholderNote',
+ directives: { SafeHtml },
components: {
userAvatarLink,
TimelineEntryItem,
@@ -34,6 +37,9 @@ export default {
},
computed: {
...mapGetters(['getUserData']),
+ renderedNote() {
+ return renderMarkdown(this.note.body);
+ },
},
};
</script>
@@ -57,9 +63,7 @@ export default {
</div>
</div>
<div class="note-body">
- <div class="note-text md">
- <p>{{ note.body }}</p>
- </div>
+ <div v-safe-html="renderedNote" class="note-text md"></div>
</div>
</div>
</timeline-entry-item>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 149909d263e..c3d861d74bc 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -111,7 +111,7 @@ export default {
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
<span v-safe-html="actionTextHtml"></span>
- <template v-if="canSeeDescriptionVersion" slot="extra-controls">
+ <template v-if="canSeeDescriptionVersion" #extra-controls>
&middot;
<gl-button
variant="link"
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
index ff2847624c5..e37a663ace3 100644
--- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
+++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
@@ -27,9 +27,13 @@ export default {
title() {
return this.isCurrentUser
? s__('OnCallSchedules|You are currently a part of:')
- : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), {
- name: this.userName,
- });
+ : sprintf(
+ s__('OnCallSchedules|User %{name} is currently part of:'),
+ {
+ name: this.userName,
+ },
+ false,
+ );
},
footer() {
return this.isCurrentUser
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index d05e45e90b3..79a9e1fca8c 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -169,6 +169,12 @@ export default {
methods: {
filterItemsByStatus(tabIndex) {
this.resetPagination();
+ const activeStatusTab = this.statusTabs[tabIndex];
+
+ if (activeStatusTab == null) {
+ return;
+ }
+
const { filters, status } = this.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
new file mode 100644
index 00000000000..110c6c73bad
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
@@ -0,0 +1,30 @@
+import ProjectAvatar from './project_avatar.vue';
+
+export default {
+ component: ProjectAvatar,
+ title: 'vue_shared/components/project_avatar',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { ProjectAvatar },
+ props: Object.keys(argTypes),
+ template: '<project-avatar v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ projectAvatarUrl:
+ 'https://gitlab.com/uploads/-/system/project/avatar/278964/logo-extra-whitespace.png?width=64',
+ projectName: 'GitLab',
+};
+
+export const FallbackAvatar = Template.bind({});
+FallbackAvatar.args = {
+ projectName: 'GitLab',
+};
+
+export const EmptyAltTag = Template.bind({});
+EmptyAltTag.args = {
+ ...Default.args,
+ alt: '',
+};
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
new file mode 100644
index 00000000000..f16187022a5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlAvatar } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ },
+ props: {
+ projectName: {
+ type: String,
+ required: true,
+ },
+ projectAvatarUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ size: {
+ type: Number,
+ default: 32,
+ required: false,
+ },
+ alt: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ avatarAlt() {
+ return this.alt ?? this.projectName;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar
+ shape="rect"
+ :entity-name="projectName"
+ :src="projectAvatarUrl"
+ :alt="avatarAlt"
+ :size="size"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index ddc8bbf9b27..69f43c9e464 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -4,7 +4,7 @@ import { GlButton, GlIcon } from '@gitlab/ui';
import { isString } from 'lodash';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
-import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
+import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
export default {
name: 'ProjectListItem',
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 580e1668f41..d55c93fd146 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -194,7 +194,7 @@ export default {
<template v-if="selectedPlatform">
<h5>
{{ $options.i18n.architecture }}
- <gl-loading-icon v-if="$apollo.loading" inline />
+ <gl-loading-icon v-if="$apollo.loading" size="sm" inline />
</h5>
<gl-dropdown class="gl-mb-3" :text="selectedArchitectureName">
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
deleted file mode 100644
index bb1a8fae7b0..00000000000
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import $ from 'jquery';
-import 'select2';
-import { loadCSSFile } from '~/lib/utils/css_utils';
-
-export default {
- // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'Select2Select',
- props: {
- options: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- value: {
- type: String,
- required: false,
- default: '',
- },
- },
-
- watch: {
- value() {
- $(this.$refs.dropdownInput).val(this.value).trigger('change');
- },
- },
-
- mounted() {
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $(this.$refs.dropdownInput)
- .val(this.value)
- .select2(this.options)
- .on('change', (event) => this.$emit('input', event.target.value));
- })
- .catch(() => {});
- },
-
- beforeDestroy() {
- $(this.$refs.dropdownInput).select2('destroy');
- },
-};
-</script>
-
-<template>
- <input ref="dropdownInput" type="hidden" />
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
index bbc7e6e7a6e..5c3a6852219 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -10,8 +10,9 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
name: 'CopyableField',
components: {
- GlLoadingIcon,
ClipboardButton,
+ GlLoadingIcon,
+ GlSprintf,
},
props: {
value: {
@@ -48,12 +49,6 @@ export default {
loadingIconLabel() {
return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name });
},
- templateText() {
- return sprintf(this.$options.i18n.templateText, {
- name: this.name,
- value: this.value,
- });
- },
},
i18n: {
loadingIconLabel: __('Loading %{name}'),
@@ -78,10 +73,13 @@ export default {
class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"
:title="value"
>
- {{ templateText }}
+ <gl-sprintf :message="$options.i18n.templateText">
+ <template #name>{{ name }}</template>
+ <template #value>{{ value }}</template>
+ </gl-sprintf>
</span>
- <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" />
+ <gl-loading-icon v-if="isLoading" size="sm" inline :label="loadingIconLabel" />
<clipboard-button v-else size="small" v-bind="clipboardProps" />
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
index 075681de320..4531fafbf72 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
@@ -104,7 +104,7 @@ export default {
<collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" />
<div class="title">
{{ label }}
- <gl-loading-icon v-if="isLoading" :inline="true" />
+ <gl-loading-icon v-if="isLoading" size="sm" :inline="true" />
<div class="float-right">
<button
v-if="editable && !editing"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index 320e2048f1c..12daaea8758 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -148,7 +148,7 @@ export default {
@hide="handleDropdownHide"
>
<template #button-content
- ><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{
+ ><gl-loading-icon v-if="moveInProgress" size="sm" class="gl-mr-3" />{{
dropdownButtonTitle
}}</template
>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index f8cc981ba3d..3ec33a653b8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -108,7 +108,7 @@ export default {
class="float-left d-flex align-items-center"
@click="handleCreateClick"
>
- <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
+ <gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
<gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 86788a84260..9914bfc6026 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -48,6 +48,12 @@ export default {
}
return this.labels;
},
+ showDropdownFooter() {
+ return (
+ (this.isDropdownVariantSidebar || this.isDropdownVariantEmbedded) &&
+ (this.allowLabelCreate || this.labelsManagePath)
+ );
+ },
showNoMatchingResultsMessage() {
return Boolean(this.searchKey) && this.visibleLabels.length === 0;
},
@@ -192,11 +198,7 @@ export default {
</li>
</ul>
</div>
- <div
- v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
- class="dropdown-footer"
- data-testid="dropdown-footer"
- >
+ <div v-if="showDropdownFooter" class="dropdown-footer" data-testid="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
@@ -206,7 +208,7 @@ export default {
{{ footerCreateLabelTitle }}
</gl-link>
</li>
- <li>
+ <li v-if="labelsManagePath">
<gl-link
:href="labelsManagePath"
class="gl-display-flex flex-row text-break-word label-item"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
index 813de528c0b..aad754e15b0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -26,7 +26,7 @@ export default {
<div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
- <gl-loading-icon v-show="labelsSelectInProgress" inline />
+ <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
<gl-button
variant="link"
class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
index 89f96ab916b..178be0f6da0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
@@ -16,7 +16,9 @@ export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
- flash(__('Error fetching labels.'));
+ createFlash({
+ message: __('Error fetching labels.'),
+ });
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
@@ -32,7 +34,9 @@ export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LA
export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
export const receiveCreateLabelFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_LABEL_FAILURE);
- flash(__('Error creating label.'));
+ createFlash({
+ message: __('Error creating label.'),
+ });
};
export const createLabel = ({ state, dispatch }, label) => {
dispatch('requestCreateLabel');
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 55716e1105e..2e0a57f15dd 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -1,3 +1,4 @@
+import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
@@ -66,5 +67,16 @@ export default {
candidateLabel.touched = true;
candidateLabel.set = !candidateLabel.set;
}
+
+ if (isScopedLabel(candidateLabel)) {
+ const scopedBase = scopedLabelKey(candidateLabel);
+ const currentActiveScopedLabel = state.labels.find(({ title }) => {
+ return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title;
+ });
+
+ if (currentActiveScopedLabel) {
+ currentActiveScopedLabel.set = false;
+ }
+ }
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index a7f20fbe851..4651e7a1576 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -117,7 +117,7 @@ export default {
data-testid="create-button"
@click="createLabel"
>
- <gl-loading-icon v-if="labelCreateInProgress" :inline="true" class="mr-1" />
+ <gl-loading-icon v-if="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
<gl-button
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
index 5d1663bc1fd..b6d14965cfa 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
@@ -26,7 +26,7 @@ export default {
<div class="title hide-collapsed gl-mb-3">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
- <gl-loading-icon v-show="labelsSelectInProgress" inline />
+ <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
<gl-button
variant="link"
class="float-right js-sidebar-dropdown-toggle"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index 46ccb9470e5..58a940bca3b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -1,7 +1,6 @@
<script>
import { GlLabel } from '@gitlab/ui';
-import { mapState } from 'vuex';
-
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
@@ -14,15 +13,26 @@ export default {
required: false,
default: false,
},
- },
- computed: {
- ...mapState([
- 'selectedLabels',
- 'allowLabelRemove',
- 'allowScopedLabels',
- 'labelsFilterBasePath',
- 'labelsFilterParam',
- ]),
+ selectedLabels: {
+ type: Array,
+ required: true,
+ },
+ allowLabelRemove: {
+ type: Boolean,
+ required: true,
+ },
+ allowScopedLabels: {
+ type: Boolean,
+ required: true,
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: true,
+ },
+ labelsFilterParam: {
+ type: String,
+ required: true,
+ },
},
methods: {
labelFilterUrl(label) {
@@ -33,6 +43,9 @@ export default {
scopedLabel(label) {
return this.allowScopedLabels && isScopedLabel(label);
},
+ removeLabel(labelId) {
+ this.$emit('onLabelRemove', getIdFromGraphQLId(labelId));
+ },
},
};
</script>
@@ -43,12 +56,14 @@ export default {
'has-labels': selectedLabels.length,
}"
class="hide-collapsed value issuable-show-labels js-value"
+ data-testid="value-wrapper"
>
- <span v-if="!selectedLabels.length" class="text-secondary">
+ <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder">
<slot></slot>
</span>
- <template v-for="label in selectedLabels" v-else>
+ <template v-else>
<gl-label
+ v-for="label in selectedLabels"
:key="label.id"
data-qa-selector="selected_label_content"
:data-qa-label-name="label.title"
@@ -60,7 +75,7 @@ export default {
:show-close-button="allowLabelRemove"
:disabled="disableLabels"
tooltip-placement="top"
- @close="$emit('onLabelRemove', label.id)"
+ @close="removeLabel(label.id)"
/>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
new file mode 100644
index 00000000000..1c2fd3bb7c0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql
@@ -0,0 +1,15 @@
+query issueLabels($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: issue(iid: $iid) {
+ id
+ labels {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 7728c758e18..87f36a5bb72 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -11,6 +11,7 @@ import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
+import issueLabelsQuery from './graphql/issue_labels.query.graphql';
import labelsSelectModule from './store';
Vue.use(Vuex);
@@ -24,6 +25,7 @@ export default {
DropdownContents,
DropdownValueCollapsed,
},
+ inject: ['iid', 'projectPath'],
props: {
allowLabelRemove: {
type: Boolean,
@@ -119,8 +121,23 @@ export default {
data() {
return {
contentIsOnViewport: true,
+ issueLabels: [],
};
},
+ apollo: {
+ issueLabels: {
+ query: issueLabelsQuery,
+ variables() {
+ return {
+ iid: this.iid,
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.labels.nodes || [];
+ },
+ },
+ },
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
...mapGetters([
@@ -293,7 +310,7 @@ export default {
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
ref="dropdownButtonCollapsed"
- :labels="selectedLabels"
+ :labels="issueLabels"
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title
@@ -302,6 +319,11 @@ export default {
/>
<dropdown-value
:disable-labels="labelsSelectInProgress"
+ :selected-labels="issueLabels"
+ :allow-label-remove="allowLabelRemove"
+ :allow-scoped-labels="allowScopedLabels"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
@onLabelRemove="$emit('onLabelRemove', $event)"
>
<slot></slot>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
index 2b96b159ca3..935f020f559 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
@@ -1,4 +1,4 @@
-import { deprecatedCreateFlash as flash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
@@ -16,7 +16,9 @@ export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
- flash(__('Error fetching labels.'));
+ createFlash({
+ message: __('Error fetching labels.'),
+ });
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
index 131c6e6fb57..1c03d95f37b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
@@ -1,3 +1,4 @@
+import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
@@ -55,5 +56,16 @@ export default {
candidateLabel.touched = true;
candidateLabel.set = !candidateLabel.set;
}
+
+ if (isScopedLabel(candidateLabel)) {
+ const scopedBase = scopedLabelKey(candidateLabel);
+ const currentActiveScopedLabel = state.labels.find(
+ ({ title }) => title.indexOf(scopedBase) === 0 && title !== candidateLabel.title,
+ );
+
+ if (currentActiveScopedLabel) {
+ currentActiveScopedLabel.set = false;
+ }
+ }
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
new file mode 100644
index 00000000000..d2afc02233e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -0,0 +1,23 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import TodoButton from './todo_button.vue';
+
+export default {
+ component: TodoButton,
+ title: 'vue_shared/components/todo_toggle/todo_button',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { TodoButton },
+ props: Object.keys(argTypes),
+ template: '<todo-button v-bind="$props" v-on="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.argTypes = {
+ isTodo: {
+ description: 'True if to-do is unresolved (i.e. not "done")',
+ control: { type: 'boolean' },
+ },
+ click: { action: 'clicked' },
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue
new file mode 100644
index 00000000000..e6229cf0a93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { todoLabel } from './utils';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ isTodo: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ computed: {
+ buttonLabel() {
+ return todoLabel(this.isTodo);
+ },
+ },
+ methods: {
+ updateGlobalTodoCount(additionalTodoCount) {
+ const countContainer = document.querySelector('.js-todos-count');
+ if (countContainer === null) return;
+ const currentCount = parseInt(countContainer.innerText, 10);
+ const todoToggleEvent = new CustomEvent('todo:toggle', {
+ detail: {
+ count: Math.max(currentCount + additionalTodoCount, 0),
+ },
+ });
+
+ document.dispatchEvent(todoToggleEvent);
+ },
+ incrementGlobalTodoCount() {
+ this.updateGlobalTodoCount(1);
+ },
+ decrementGlobalTodoCount() {
+ this.updateGlobalTodoCount(-1);
+ },
+ onToggle(event) {
+ if (this.isTodo) {
+ this.decrementGlobalTodoCount();
+ } else {
+ this.incrementGlobalTodoCount();
+ }
+ this.$emit('click', event);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="onToggle($event)">
+ {{ buttonLabel }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js
new file mode 100644
index 00000000000..59e72a2ffe3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js
@@ -0,0 +1,5 @@
+import { __ } from '~/locale';
+
+export const todoLabel = (hasTodo) => {
+ return hasTodo ? __('Mark as done') : __('Add a to do');
+};
diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index c3bddabea21..fdf0c9baee3 100644
--- a/app/assets/javascripts/vue_shared/components/editor_lite.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -1,9 +1,9 @@
<script>
import { debounce } from 'lodash';
import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants';
-import Editor from '~/editor/editor_lite';
+import Editor from '~/editor/source_editor';
-function initEditorLite({ el, ...args }) {
+function initSourceEditor({ el, ...args }) {
const editor = new Editor({
scrollbar: {
alwaysConsumeMouseWheel: false,
@@ -64,7 +64,7 @@ export default {
},
},
mounted() {
- this.editor = initEditorLite({
+ this.editor = initSourceEditor({
el: this.$refs.editor,
blobPath: this.fileName,
blobContent: this.value,
@@ -93,7 +93,7 @@ export default {
</script>
<template>
<div
- :id="`editor-lite-${fileGlobalId}`"
+ :id="`source-editor-${fileGlobalId}`"
ref="editor"
data-editor-loading
@[$options.readyEvent]="$emit($options.readyEvent)"
diff --git a/app/assets/javascripts/vue_shared/components/todo_button.vue b/app/assets/javascripts/vue_shared/components/todo_button.vue
deleted file mode 100644
index 935d222a1a9..00000000000
--- a/app/assets/javascripts/vue_shared/components/todo_button.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- isTodo: {
- type: Boolean,
- required: false,
- default: true,
- },
- },
- computed: {
- buttonLabel() {
- return this.isTodo ? __('Mark as done') : __('Add a to do');
- },
- },
-};
-</script>
-
-<template>
- <gl-button v-bind="$attrs" :aria-label="buttonLabel" @click="$emit('click', $event)">
- {{ buttonLabel }}
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index deac24d2270..f387f8ca128 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -72,7 +72,11 @@ export default {
<template v-else>
<div class="gl-mb-3">
<h5 class="gl-m-0">
- <user-name-with-status :name="user.name" :availability="availabilityStatus" />
+ <user-name-with-status
+ :name="user.name"
+ :availability="availabilityStatus"
+ :pronouns="user.pronouns"
+ />
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index 04e44aa2ed1..b85cae0c64f 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -96,9 +96,6 @@ export default {
},
},
searchUsers: {
- // TODO Remove error policy
- // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
- errorPolicy: 'all',
query: searchUsers,
variables() {
return {
@@ -111,28 +108,10 @@ export default {
return !this.isEditing;
},
update(data) {
- // TODO Remove null filter (BE fix required)
- // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
- error({ graphQLErrors }) {
- // TODO This error suppression is temporary (BE fix required)
- // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
- const isNullError = ({ message }) => {
- return message === 'Cannot return null for non-nullable field GroupMember.user';
- };
-
- if (graphQLErrors?.length > 0 && graphQLErrors.every(isNullError)) {
- // only null-related errors exist, suppress them.
- // eslint-disable-next-line no-console
- console.error(
- "Suppressing the error 'Cannot return null for non-nullable field GroupMember.user'. Please see https://gitlab.com/gitlab-org/gitlab/-/issues/329750",
- );
- this.isSearching = false;
- return;
- }
-
+ error() {
this.$emit('error');
this.isSearching = false;
},
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 4bd3e352fd2..5ba7c107c12 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -93,9 +93,8 @@ export default {
tooltip: '',
attrs: {
'data-qa-selector': 'edit_button',
- 'data-track-event': 'click_edit',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- 'data-track-label': 'Edit',
+ 'data-track-action': 'click_consolidated_edit',
+ 'data-track-label': 'edit',
},
...handleOptions,
};
@@ -127,9 +126,8 @@ export default {
tooltip: '',
attrs: {
'data-qa-selector': 'web_ide_button',
- 'data-track-event': 'click_edit_ide',
- // eslint-disable-next-line @gitlab/require-i18n-strings
- 'data-track-label': 'Web IDE',
+ 'data-track-action': 'click_consolidated_edit_ide',
+ 'data-track-label': 'web_ide',
},
...handleOptions,
};
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index e9983af5401..1b20ae57563 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -16,14 +16,9 @@ export default {
type: Array,
required: true,
},
- experiment: {
- type: String,
- required: false,
- default: null,
- },
},
created() {
- const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: this.experiment });
+ const trackingMixin = Tracking.mixin();
const trackingInstance = new Vue({
...trackingMixin,
render() {
@@ -35,7 +30,7 @@ export default {
};
</script>
<template>
- <div class="container">
+ <div class="container gl-display-flex gl-flex-direction-column">
<h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
{{ title }}
</h2>
@@ -43,11 +38,12 @@ export default {
<div
v-for="panel in panels"
:key="panel.name"
- class="new-namespace-panel-wrapper gl-display-inline-block gl-px-3 gl-mb-5"
+ class="new-namespace-panel-wrapper gl-display-inline-block gl-float-left gl-px-3 gl-mb-5"
>
<a
:href="`#${panel.name}`"
- :data-qa-selector="`${panel.name}_link`"
+ data-qa-selector="panel_link"
+ :data-qa-panel-name="panel.name"
class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!"
@click="track('click_tab', { label: panel.name })"
>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index a2b432d11f4..c1e8376d656 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -36,11 +36,6 @@ export default {
type: String,
required: true,
},
- experiment: {
- type: String,
- required: false,
- default: null,
- },
},
data() {
@@ -103,12 +98,7 @@ export default {
</script>
<template>
- <welcome-page
- v-if="activePanelName === null"
- :panels="panels"
- :title="title"
- :experiment="experiment"
- >
+ <welcome-page v-if="!activePanelName" :panels="panels" :title="title">
<template #footer>
<slot name="welcome-footer"> </slot>
</template>
diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js
index bfea2bedd40..fb52b31c2c8 100644
--- a/app/assets/javascripts/vue_shared/plugins/global_toast.js
+++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js
@@ -2,7 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
Vue.use(GlToast);
-const instance = new Vue();
+export const instance = new Vue();
export default function showGlobalToast(...args) {
return instance.$toast.show(...args);
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 12e5f634a08..0ff858e6afc 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -5,6 +5,10 @@ import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../provider';
+function mutationSettingsForFeatureType(type) {
+ return featureToMutationMap[type];
+}
+
export default {
apolloProvider,
components: {
@@ -19,7 +23,7 @@ export default {
variant: {
type: String,
required: false,
- default: 'success',
+ default: 'confirm',
},
category: {
type: String,
@@ -33,17 +37,19 @@ export default {
};
},
computed: {
- featureSettings() {
- return featureToMutationMap[this.feature.type];
+ mutationSettings() {
+ return mutationSettingsForFeatureType(this.feature.type);
},
},
methods: {
async mutate() {
this.isLoading = true;
try {
- const mutation = this.featureSettings;
- const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath));
- const { errors, successPath } = data[mutation.mutationId];
+ const { mutationSettings } = this;
+ const { data } = await this.$apollo.mutate(
+ mutationSettings.getMutationPayload(this.projectPath),
+ );
+ const { errors, successPath } = data[mutationSettings.mutationId];
if (errors.length > 0) {
throw new Error(errors[0]);
@@ -62,6 +68,22 @@ export default {
}
},
},
+ /**
+ * Returns a boolean representing whether this component can be rendered for
+ * the given feature. Useful for parent components to determine whether or
+ * not to render this component.
+ * @param {Object} feature The feature to check.
+ * @returns {boolean}
+ */
+ canRender(feature) {
+ const { available, configured, canEnableByMergeRequest, type } = feature;
+ return (
+ canEnableByMergeRequest &&
+ available &&
+ !configured &&
+ Boolean(mutationSettingsForFeatureType(type))
+ );
+ },
i18n: {
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
noSuccessPathError: s__(
@@ -74,6 +96,7 @@ export default {
<template>
<gl-button
v-if="!feature.configured"
+ data-testid="configure-via-mr-button"
:loading="isLoading"
:variant="variant"
:category="category"
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
index 8fdc5ca78db..f3dd26b02cb 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
@@ -76,6 +76,7 @@ export default {
<template>
<security-report-download-dropdown
+ :title="s__('SecurityReports|Download results')"
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
index 5d39d740c07..4178c5d1170 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
@@ -21,6 +21,16 @@ export default {
required: false,
default: false,
},
+ text: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
artifactText({ name }) {
@@ -35,7 +45,8 @@ export default {
<template>
<gl-dropdown
v-gl-tooltip
- :text="s__('SecurityReports|Download results')"
+ :text="text"
+ :title="title"
:loading="loading"
icon="download"
size="small"
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index 1cdcf87097f..4a50dfbd82f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -22,6 +22,7 @@ export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
+export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index d7a3d4e611e..3e0310e173e 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -200,6 +200,7 @@ export default {
<template #action-buttons>
<security-report-download-dropdown
+ :text="s__('SecurityReports|Download results')"
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
@@ -228,6 +229,7 @@ export default {
<template #action-buttons>
<security-report-download-dropdown
+ :text="s__('SecurityReports|Download results')"
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js
index 741690886b7..bc3741a3880 100644
--- a/app/assets/javascripts/vuex_shared/bindings.js
+++ b/app/assets/javascripts/vuex_shared/bindings.js
@@ -6,7 +6,7 @@
* @param {string} list[].getter - the name of the getter, leave it empty to not use a getter
* @param {string} list[].updateFn - the name of the action, leave it empty to use the default action
* @param {string} defaultUpdateFn - the default function to dispatch
- * @param {string} root - the key of the state where to search fo they keys described in list
+ * @param {string|function} root - the key of the state where to search for the keys described in list
* @returns {Object} a dictionary with all the computed properties generated
*/
export const mapComputed = (list, defaultUpdateFn, root) => {
@@ -21,6 +21,10 @@ export const mapComputed = (list, defaultUpdateFn, root) => {
if (getter) {
return this.$store.getters[getter];
} else if (root) {
+ if (typeof root === 'function') {
+ return root(this.$store.state)[key];
+ }
+
return this.$store.state[root][key];
}
return this.$store.state[key];
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 4ee586527b5..b74dba686ad 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -68,7 +68,7 @@ export default {
:open="open"
@close="closeDrawer"
>
- <template #header>
+ <template #title>
<h4 class="page-title gl-my-2">{{ __("What's new") }}</h4>
</template>
<template v-if="features.length">
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index 90aab7ce342..30db4e2296d 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -57,4 +57,8 @@ body.gl-dark {
}
}
}
+
+ .md code {
+ background-color: $gray-200;
+ }
}
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index c8f69bfdbaf..3885134e276 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -67,15 +67,12 @@ $avatar-sizes: (
)
);
-$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
- $identicon-orange, $identicon-gray;
-
.avatar,
.avatar-container {
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
- border: 1px solid $gray-normal;
+ border: 1px solid $t-gray-a-08;
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
@@ -125,8 +122,8 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
.identicon {
text-align: center;
vertical-align: top;
- color: $identicon-text-color;
- background-color: $identicon-gray;
+ color: $gray-900;
+ background-color: $gray-50;
// Sizes
@each $size, $size-config in $avatar-sizes {
@@ -143,9 +140,9 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i
}
// Background colors
- @for $i from 1 through length($identicon-backgrounds) {
+ @for $i from 1 through length($gl-avatar-identicon-bgs) {
&.bg#{$i} {
- background-color: nth($identicon-backgrounds, $i);
+ background-color: nth($gl-avatar-identicon-bgs, $i);
}
}
}
diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss
index d769ea73101..bcd06974813 100644
--- a/app/assets/stylesheets/components/batch_comments/review_bar.scss
+++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss
@@ -2,13 +2,15 @@
position: fixed;
bottom: 0;
left: 0;
- width: 100%;
- background: $white;
z-index: $zindex-dropdown-menu;
- padding: 7px 0 6px; // to keep aligned with "collapse sidebar" button on the left sidebar
- border-top: 1px solid $border-color;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: $toggle-sidebar-height;
padding-left: $contextual-sidebar-width;
padding-right: $gutter_collapsed_width;
+ background: $white;
+ border-top: 1px solid $border-color;
transition: padding $sidebar-transition-duration;
.page-with-icon-sidebar & {
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 2fbdaaaf467..804cc205279 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -39,7 +39,7 @@
@import 'framework/selects';
@import 'framework/sidebar';
@import 'framework/contextual_sidebar_header';
-@import 'framework/contextual_sidebar_refactoring/contextual_sidebar';
+@import 'framework/contextual_sidebar';
@import 'framework/tables';
@import 'framework/notes';
@import 'framework/tabs';
@@ -69,5 +69,5 @@
@import 'framework/system_messages';
@import 'framework/spinner';
@import 'framework/card';
-@import 'framework/editor-lite';
+@import 'framework/source_editor';
@import 'framework/diffs';
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 1ea50281204..f5002a342b6 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_variant.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -1,81 +1,4 @@
//
-// VARIABLES
-//
-
-$top-level-item-color: $purple-900;
-
-//
-// TEMPORARY OVERRIDES
-// Needed while we serve both *_base and *_variant stylesheets
-// TODO: These have to be removed during the ':sidebar_refactor' flag rollout
-//
-&.gl-dark .nav-sidebar li.active {
- box-shadow: none;
-}
-
-&.gl-dark .nav-sidebar .sidebar-sub-level-items {
- box-shadow: none;
- border: 1px solid $border-color;
-}
-
-&.gl-dark .sidebar-top-level-items .context-header a .avatar-container.rect-avatar .avatar.s32 {
- color: $white;
-}
-
-&.gl-dark .nav-sidebar li a,
-&.gl-dark .toggle-sidebar-button .collapse-text,
-&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-left,
-&.gl-dark .toggle-sidebar-button .icon-chevron-double-lg-right,
-&.gl-dark .sidebar-top-level-items .context-header a .sidebar-context-title,
-&.gl-dark .nav-sidebar-inner-scroll > div.context-header a .sidebar-context-title,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a:hover,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item.active a,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
- color: $gray-darkest;
-}
-
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a:hover,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item.active a,
-&.gl-dark .nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
- @include gl-mt-0;
-}
-
-&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item a,
-&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item a:hover,
-&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item.active a,
-&.gl-dark .nav-sidebar a:not(.has-sub-items) + .sidebar-sub-level-items .fly-out-top-item .fly-out-top-item-container {
- background: $white;
- color: $gray-darkest;
-
- &::before {
- border-right-color: $white;
- }
-}
-
-&.gl-dark .nav-sidebar .sidebar-sub-level-items {
- background-color: $white;
-}
-
-&.ui-indigo .nav-sidebar li.active:not(.fly-out-top-item) > a {
- color: $top-level-item-color;
-}
-
-&.ui-indigo .nav-sidebar li.active .nav-icon-container svg {
- fill: $top-level-item-color;
-}
-
-.nav-sidebar {
- box-shadow: none;
-
- li.active {
- background-color: transparent;
- box-shadow: none !important; // TODO: This should be updated in `theme_helper.scss` together with ':sidebar_refactor' rollout
- }
-}
-
-//
// MIXINS
//
@@ -112,7 +35,6 @@ $top-level-item-color: $purple-900;
.icon-chevron-double-lg-left {
@include gl-rotate-180;
- @include gl-display-block; // TODO: shouldn't be needed after the flag roll out
@include gl-m-0;
}
}
@@ -219,15 +141,10 @@ $top-level-item-color: $purple-900;
.avatar.s32 {
@extend .rect-avatar.s32;
- //color: $gray-900;
box-shadow: $avatar-box-shadow;
}
}
}
-
- .sidebar-context-title {
- color: $top-level-item-color;
- }
}
@mixin top-level-item {
@@ -258,8 +175,6 @@ $top-level-item-color: $purple-900;
@include gl-cursor-default;
@include gl-pointer-events-none;
@include gl-font-sm;
- background-color: $purple-900;
- color: $white;
@if $has-sub-items {
@include gl-mt-0;
@@ -269,7 +184,8 @@ $top-level-item-color: $purple-900;
@include gl-my-n2;
@include gl-mt-0;
@include gl-relative;
- background-color: $black;
+ @include gl-text-white;
+ background: var(--black, $black);
strong {
@include gl-font-weight-normal;
@@ -287,6 +203,7 @@ $top-level-item-color: $purple-900;
border-top: $gl-spacing-scale-2 solid transparent;
border-bottom: $gl-spacing-scale-2 solid transparent;
border-right: $gl-spacing-scale-2 solid $black;
+ border-right-color: var(--black, $black);
}
}
}
@@ -343,7 +260,7 @@ $top-level-item-color: $purple-900;
a {
@include gl-text-decoration-none;
- color: $top-level-item-color;
+ color: $gray-900;
}
li {
@@ -392,13 +309,19 @@ $top-level-item-color: $purple-900;
}
a.has-sub-items + .sidebar-sub-level-items {
- @include gl-mt-n2;
-
.fly-out-top-item {
@include fly-out-top-item($has-sub-items: true);
}
}
+ a.has-sub-items + .sidebar-sub-level-items.fly-out-list {
+ @include gl-mt-n2;
+
+ &.is-above {
+ @include gl-mt-2;
+ }
+ }
+
@media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) {
&:not(.sidebar-expanded-mobile) {
@include collapse-contextual-sidebar;
@@ -445,8 +368,8 @@ $top-level-item-color: $purple-900;
}
.badge.badge-pill {
- @include gl-font-weight-normal; // TODO: update in `theme_helper.scss`
- color: $blue-700; // TODO: update in `theme_helper.scss`
+ @include gl-font-weight-normal;
+ color: $blue-700;
}
}
}
@@ -484,7 +407,6 @@ $top-level-item-color: $purple-900;
@include side-panel-toggle;
background-color: $gray-50;
border-top: 1px solid $border-color;
- color: $top-level-item-color;
position: fixed;
bottom: 0;
width: $contextual-sidebar-width;
@@ -538,14 +460,10 @@ $top-level-item-color: $purple-900;
//
// PANELS-SPECIFIC
-// TODO: Check whether we can remove these in favor of the utility-classes
//
.settings-avatar {
- background-color: $white;
-
svg {
- fill: $gl-text-color-secondary;
margin: auto;
}
}
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_header.scss b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
index fdd03f4cdc8..7159dadf7cc 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar_header.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
@@ -32,6 +32,7 @@
.sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
+ color: $gray-900;
&.text-secondary {
font-weight: normal;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss
deleted file mode 100644
index 905ac260203..00000000000
--- a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-body:not(.sidebar-refactoring) {
- @import 'contextual_sidebar_base';
-}
-
-body.sidebar-refactoring {
- @import 'contextual_sidebar_variant';
-}
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss b/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss
deleted file mode 100644
index 306a9b74ebd..00000000000
--- a/app/assets/stylesheets/framework/contextual_sidebar_refactoring/contextual_sidebar_base.scss
+++ /dev/null
@@ -1,386 +0,0 @@
-@mixin collapse-contextual-sidebar-content {
-
- @include context-header-collapsed;
-
- .sidebar-top-level-items > li {
- .sidebar-sub-level-items {
- &:not(.flyout-list) {
- display: none;
- }
- }
- }
-
- .nav-icon-container {
- margin-right: 0;
- }
-
- .toggle-sidebar-button {
- padding: 16px;
- width: $contextual-sidebar-collapsed-width - 1px;
-
- .collapse-text,
- .icon-chevron-double-lg-left {
- display: none;
- }
-
- .icon-chevron-double-lg-right {
- display: block;
- margin: 0;
- }
- }
-}
-
-@mixin collapse-contextual-sidebar {
- width: $contextual-sidebar-collapsed-width;
-
- .nav-sidebar-inner-scroll {
- overflow-x: hidden;
- }
-
- .badge.badge-pill:not(.fly-out-badge),
- .nav-item-name {
- @include gl-sr-only;
- }
-
- .sidebar-top-level-items > li > a {
- min-height: 45px;
- }
-
- .fly-out-top-item {
- display: block;
- }
-
- .avatar-container {
- margin: 0 auto;
- }
-}
-
-@at-root {
- .page-with-contextual-sidebar {
- transition: padding-left $sidebar-transition-duration;
-
- @include media-breakpoint-up(md) {
- padding-left: $contextual-sidebar-collapsed-width;
- }
-
- @include media-breakpoint-up(xl) {
- padding-left: $contextual-sidebar-width;
- }
-
- .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
- padding: 10px 0 15px;
- }
- }
-
- .page-with-icon-sidebar {
- @include media-breakpoint-up(md) {
- padding-left: $contextual-sidebar-collapsed-width;
- }
- }
-
- .settings-avatar {
- background-color: $white;
-
- svg {
- fill: $gl-text-color-secondary;
- margin: auto;
- }
- }
-
- .nav-sidebar {
- transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
- position: fixed;
- z-index: 600;
- width: $contextual-sidebar-width;
- top: $header-height;
- bottom: 0;
- left: 0;
- background-color: $gray-light;
- box-shadow: inset -1px 0 0 $border-color;
- transform: translate3d(0, 0, 0);
-
- &:not(.sidebar-collapsed-desktop) {
- @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
- box-shadow: inset -1px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color;
- }
- }
-
- &.sidebar-collapsed-desktop {
- @include collapse-contextual-sidebar;
- }
-
- &.sidebar-expanded-mobile {
- left: 0;
- }
-
- a {
- text-decoration: none;
- }
-
- ul {
- padding-left: 0;
- list-style: none;
- }
-
- li {
- white-space: nowrap;
-
- a {
- transition: padding $sidebar-transition-duration;
- display: flex;
- align-items: center;
- padding: 12px $gl-padding;
- color: $gl-text-color-secondary;
- }
-
- .nav-item-name {
- flex: 1;
- }
-
- &.active {
- > a {
- font-weight: $gl-font-weight-bold;
- }
- }
- }
-
- @include media-breakpoint-down(sm) {
- left: (-$contextual-sidebar-width);
- }
-
- .nav-icon-container {
- display: flex;
- margin-right: 8px;
- }
-
- .fly-out-top-item {
- display: none;
- }
-
- svg {
- height: 16px;
- width: 16px;
- }
-
- @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) {
- &:not(.sidebar-expanded-mobile) {
- @include collapse-contextual-sidebar;
- @include collapse-contextual-sidebar-content;
- }
- }
- }
-
- .nav-sidebar-inner-scroll {
- height: 100%;
- width: 100%;
- overflow: auto;
- }
-
- .sidebar-sub-level-items {
- display: none;
- padding-bottom: 8px;
-
- > li {
- a {
- padding: 8px 16px 8px 40px;
-
- &:hover,
- &:focus {
- background: $link-active-background;
- color: $gl-text-color;
- }
- }
-
- &.active {
- a {
- &,
- &:hover,
- &:focus {
- background: $link-active-background;
- }
- }
- }
- }
- }
-
- .sidebar-top-level-items {
- margin-bottom: 60px;
-
- > li {
- > a {
- @include media-breakpoint-up(sm) {
- margin-right: 1px;
- }
-
- &:hover {
- color: $gl-text-color;
- }
- }
-
- &.is-showing-fly-out {
- > a {
- margin-right: 1px;
- }
-
- .sidebar-sub-level-items {
- @include media-breakpoint-up(sm) {
- position: fixed;
- top: 0;
- left: 0;
- min-width: 150px;
- margin-top: -1px;
- padding: 4px 1px;
- background-color: $white;
- box-shadow: 2px 1px 3px $dropdown-shadow-color;
- border: 1px solid $gray-darker;
- border-left: 0;
- border-radius: 0 3px 3px 0;
-
- &::before {
- content: '';
- position: absolute;
- top: -30px;
- bottom: -30px;
- left: -10px;
- right: -30px;
- z-index: -1;
- }
-
- &.is-above {
- margin-top: 1px;
- }
-
- .divider {
- height: 1px;
- margin: 4px -1px;
- padding: 0;
- background-color: $dropdown-divider-bg;
- }
-
- > .active {
- box-shadow: none;
-
- > a {
- background-color: transparent;
- }
- }
-
- a {
- padding: 8px 16px;
- color: $gl-text-color;
-
- &:hover,
- &:focus {
- background-color: $gray-darker;
- }
- }
- }
- }
- }
-
- .badge.badge-pill {
- background-color: $inactive-badge-background;
- color: $gl-text-color-secondary;
- }
-
- &.active {
- background: $link-active-background;
-
- > a {
- margin-left: 4px;
- // Subtract width of left border on active element
- padding-left: $gl-padding-12;
- }
-
- .badge.badge-pill {
- font-weight: $gl-font-weight-bold;
- }
-
- .sidebar-sub-level-items:not(.is-fly-out-only) {
- display: block;
- }
- }
-
- &.active > a:hover,
- &.is-over > a {
- background-color: $link-hover-background;
- }
- }
- }
-
- // Collapsed nav
-
- .toggle-sidebar-button,
- .close-nav-button {
- @include side-panel-toggle;
- }
-
- .toggle-sidebar-button,
- .close-nav-button {
- position: fixed;
- bottom: 0;
- width: $contextual-sidebar-width - 1px;
- border-top: 1px solid $border-color;
-
- svg {
- margin-right: 8px;
- }
-
- .icon-chevron-double-lg-right {
- display: none;
- }
- }
-
- .collapse-text {
- white-space: nowrap;
- overflow: hidden;
- }
-
- .sidebar-collapsed-desktop {
- @include collapse-contextual-sidebar-content;
- }
-
- .fly-out-top-item {
- > a {
- display: flex;
- }
-
- .fly-out-badge {
- margin-left: 8px;
- }
- }
-
- .fly-out-top-item-name {
- flex: 1;
- }
-
- // Mobile nav
-
- .close-nav-button {
- display: none;
- }
-
- @include media-breakpoint-down(sm) {
- .close-nav-button {
- display: flex;
- }
-
- .toggle-sidebar-button {
- display: none;
- }
-
- .mobile-overlay {
- display: none;
-
- &.mobile-nav-open {
- display: block;
- position: fixed;
- background-color: $black-transparent;
- height: 100%;
- width: 100%;
- z-index: $zindex-dropdown-menu;
- }
- }
- }
-}
-
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index c0e9289309a..f8b1735207c 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -847,8 +847,6 @@ table.code {
.commit-stat-summary {
@include media-breakpoint-up(sm) {
- margin-left: -$gl-padding;
- padding-left: $gl-padding;
background-color: $white;
}
}
@@ -1190,3 +1188,9 @@ table.code {
margin-top: 0;
}
}
+
+// Note: Prevents tall files from appearing above sticky tabs
+.diffs .vue-recycle-scroller__item-view > div:not(.active) {
+ position: absolute;
+ bottom: 100vh;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 894eddbe1a7..144a396ea65 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -30,7 +30,9 @@
.dropdown-menu {
@include set-visible;
min-height: $dropdown-min-height;
- max-height: $dropdown-max-height;
+ // Prevents double scrollbar on dropdowns that also
+ // have max-height set on an inner scrollable element
+ max-height: $dropdown-max-height-lg;
overflow-y: auto;
&.dropdown-extended-height {
@@ -239,7 +241,7 @@
max-width: 500px;
margin-top: $dropdown-vertical-offset;
margin-bottom: 24px;
- font-size: 14px;
+ font-size: 0.875rem;
font-weight: $gl-font-weight-normal;
padding: 8px 0;
background-color: $white;
@@ -931,13 +933,9 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
.frequent-items-list-item-container {
- .frequent-items-item-avatar-container,
- .frequent-items-item-metadata-container {
- flex-shrink: 0;
- }
-
.frequent-items-item-metadata-container {
display: flex;
+ flex-shrink: 0;
flex-direction: column;
justify-content: center;
}
@@ -949,12 +947,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
white-space: nowrap;
}
- &:hover {
- .frequent-items-item-avatar-container .avatar {
- border-color: $gray-50;
- }
- }
-
.frequent-items-item-title {
font-size: $gl-font-size;
font-weight: 400;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index bda123fa7ea..5ad7ceecb2b 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -508,3 +508,25 @@ span.idiff {
}
}
}
+
+//
+// IMPORTANT PERFORMANCE OPTIMIZATION BELOW
+//
+// * :nth-of-type(1n+70) - makes sure we do not render lines 71+ right
+// away. Even though the HTML is injected in the DOM, as long as we do
+// not render those lines, the browser doesn't need to spend resources
+// calculating and repainting what's hidden.
+//
+// * :not(:last-of-type) makes sure that we output the last line of the
+// blob's snippet. This is important because the column with the line
+// numbers has auto width and is expanding based on the content in it.
+// This leads to unnecessary layout shift when the last lines of the
+// snippet are longer than two (2) digits.
+// EXAMPLE: Let's say, we have a blob with 100 lines. If we output 70
+// lines, and then, the remaining 30 (incl the line 100), it will lead
+// to the layout reflow and styles recalculation when we output line
+// 100 (because the width of '100' is always bigger than '70'). By
+// outputting the last line right away, we prevent that as the column
+// will always be expanded to the maximum needed width.
+.blob-viewer[data-loading] .file-content.code .line:nth-of-type(1n+70):not(:last-of-type),
+.blob-viewer[data-loading] .file-content.code .file-line-num:nth-of-type(1n+70):not(:last-of-type) {display: none !important;}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index d5f7ec68454..30a1c8af414 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -56,24 +56,19 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
}
.flash-alert {
- background-color: $red-100;
- color: $red-700;
+ background-color: $red-50;
}
.flash-notice {
- background-color: $blue-100;
- color: $blue-700;
+ background-color: $blue-50;
}
.flash-success {
- background-color: $theme-green-100;
- color: $green-700;
+ background-color: $green-50;
}
.flash-warning {
background-color: $orange-50;
- color: $gray-900;
- cursor: default;
}
.flash-text,
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 8639b9a7f84..65d914e47cf 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -555,7 +555,8 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
visibility: visible;
}
-.with-performance-bar .navbar-gitlab {
+.with-performance-bar .navbar-gitlab,
+.with-performance-bar .fixed-top {
top: $performance-bar-height;
}
@@ -563,7 +564,7 @@ $top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important
justify-content: center;
height: $header-height;
background: $white;
- border-bottom: 1px solid $white-normal;
+ border-bottom: 1px solid $gray-100;
.tanuki-logo,
.brand-header-logo {
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 9fe9f9a845c..d2bb1e3d555 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -149,12 +149,6 @@ ul.content-list {
margin-right: $grid-size;
display: inline-block;
- &.btn-ldap-override {
- @include media-breakpoint-up(sm) {
- margin-bottom: 0;
- }
- }
-
&.has-tooltip,
&:last-child {
margin-right: 0;
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/source_editor.scss
index 05b53e0c3d8..a967d9a71f1 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -21,11 +21,11 @@
}
}
-[id^='editor-lite-'] {
+[id^='source-editor-'] {
height: 500px;
}
-.monaco-editor.gl-editor-lite {
+.monaco-editor.gl-source-editor {
.margin-view-overlays {
.line-numbers {
@include gl-display-flex;
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 437915d5034..1cb34bea069 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -60,7 +60,8 @@
// System Header
&.with-performance-bar {
// main navigation
- header.navbar-gitlab {
+ header.navbar-gitlab,
+ .fixed-top {
top: $performance-bar-height + $system-header-height;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index d3976cfa8c7..726f8e28efe 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -634,18 +634,6 @@ $note-targe3-outside: #fffff0;
$note-targe3-inside: #ffffd3;
/*
-* Identicon
-*/
-$identicon-text-color: #525252 !default;
-$identicon-red: #ffebee !default;
-$identicon-purple: #f3e5f5 !default;
-$identicon-indigo: #e8eaf6 !default;
-$identicon-blue: #e3f2fd !default;
-$identicon-teal: #e0f2f1 !default;
-$identicon-orange: #fbe9e7 !default;
-$identicon-gray: #eee !default;
-
-/*
* Calendar
*/
$calendar-hover-bg: #ecf3fe;
diff --git a/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss b/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss
index 41bb6d107f1..db81cc7fdd4 100644
--- a/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss
+++ b/app/assets/stylesheets/page_bundles/admin/application_settings_metrics_and_profiling.scss
@@ -1,3 +1,3 @@
-.usage-data {
+.service-data-payload-container {
max-height: 400px;
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index a00a71b07e7..428bd90ddd7 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -472,6 +472,10 @@
.sidebar-collapsed-icon {
display: none;
}
+
+ .gl-drawer-header {
+ align-items: flex-start;
+ }
}
.board-header-collapsed-info-icon:hover {
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 009019a45d9..47580e37eca 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -771,6 +771,12 @@ $ide-commit-header-height: 48px;
}
.dropdown-menu-toggle {
+ background-color: var(--ide-input-background, transparent);
+
+ &:hover {
+ background-color: var(--ide-dropdown-btn-hover-background, $white-normal);
+ }
+
svg {
vertical-align: middle;
@@ -779,16 +785,6 @@ $ide-commit-header-height: 48px;
color: var(--ide-text-color-secondary, $gray-500);
}
}
-
- &:hover {
- background-color: var(--ide-dropdown-btn-hover-background, $white-normal);
- }
- }
-
- &.show {
- .dropdown-menu-toggle {
- background-color: var(--ide-input-background, $white-dark);
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/members.scss b/app/assets/stylesheets/page_bundles/members.scss
index 7b4c74b8253..62dd3dcb9c0 100644
--- a/app/assets/stylesheets/page_bundles/members.scss
+++ b/app/assets/stylesheets/page_bundles/members.scss
@@ -1,10 +1,5 @@
@import 'mixins_and_variables_and_functions';
-.project-members-title {
- padding-bottom: 10px;
- border-bottom: 1px solid $border-color;
-}
-
.invite-users-form {
.btn-success {
margin-right: 10px;
@@ -12,12 +7,6 @@
}
.member {
- &.is-overridden {
- .btn-ldap-override {
- display: none !important;
- }
- }
-
.controls {
@include media-breakpoint-up(sm) {
display: flex;
@@ -31,111 +20,12 @@
.form-group {
margin-bottom: 0;
}
-
- &.existing-title {
- @include media-breakpoint-up(sm) {
- float: left;
- }
- }
-}
-
-.member-form-control {
- @include media-breakpoint-down(xs) {
- margin-right: 0;
- width: auto;
- }
-}
-
-.member-search-btn {
- position: absolute;
- right: 4px;
- top: 0;
- height: $input-height;
- padding-left: 10px;
- padding-right: 10px;
- color: $gray-darkest;
- background: transparent;
- border: 0;
- outline: 0;
}
.members-ldap {
align-self: center;
}
-.alert-member-ldap {
- background-color: $orange-50;
-
- @include media-breakpoint-up(sm) {
- line-height: 40px;
- }
-
- > p {
- float: left;
- margin-bottom: 10px;
- color: $orange-600;
-
- @include media-breakpoint-up(sm) {
- padding-left: 55px;
- margin-bottom: 0;
- }
- }
-
- .controls {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: auto;
- }
- }
-}
-
-.btn-ldap-override {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- margin-left: 10px;
- width: auto;
- }
-}
-
-.flex-project-members-panel {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
-
- @include media-breakpoint-down(sm) {
- display: block;
-
- .flex-project-title {
- vertical-align: top;
- display: inline-block;
- max-width: 90%;
- }
- }
-
- .flex-project-title {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- .badge.badge-pill {
- height: 17px;
- line-height: 16px;
- margin-right: 5px;
- padding-top: 1px;
- padding-bottom: 1px;
- }
-
- .flex-users-form {
- flex-wrap: nowrap;
- white-space: nowrap;
- margin-left: auto;
- }
-}
-
.card {
.card-header {
.badge.badge-pill {
@@ -168,33 +58,11 @@
word-break: break-all;
}
- .form-control {
- width: inherit;
- }
-
- .btn {
- align-self: flex-start;
- }
-
@include media-breakpoint-down(sm) {
.member-access-text {
margin: 0 0 $gl-padding-4 ($grid-size * 6);
}
}
-
- @include media-breakpoint-down(xs) {
- display: block;
-
- .controls > .btn,
- .controls .member-form-control {
- margin: 0 0 $gl-padding-8;
- display: block;
- }
-
- .form-control {
- width: 100%;
- }
- }
}
@@ -231,25 +99,5 @@
float: none;
display: block;
}
-
- .dropdown-menu-toggle,
- .dropdown-menu,
- .form-control,
- .list-item-name {
- width: 100%;
- }
-
- .dropdown-menu {
- margin-top: 0;
- }
-
- .member-form-control {
- margin: 5px 0;
- }
-
- .btn {
- width: 100%;
- margin-left: 0;
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 5e9dd883635..6a20ff3b3fa 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -7,6 +7,7 @@
.diff-files-holder {
flex: 1;
min-width: 0;
+ z-index: 203;
.vue-recycle-scroller__item-wrapper {
overflow: visible;
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 03dd12ec230..08d9d24d246 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -65,15 +65,32 @@ $status-box-line-height: 26px;
line-height: $line-height-base;
padding: 14px 16px;
display: flex;
+ justify-content: space-between;
.title {
flex: 1;
flex-grow: 2;
}
- .counter {
- flex: 0;
- padding-left: 16px;
+ .issuable-count-weight {
+ white-space: nowrap;
+
+ .counter,
+ .weight {
+ color: var(--gray-500, $gray-500);
+ font-weight: $gl-font-weight-bold;
+ }
+ }
+
+ &.text-white {
+ .issuable-count-weight svg {
+ fill: $white;
+ }
+
+ .issuable-count-weight .counter,
+ .weight {
+ color: var(--white, $white);
+ }
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/new_namespace.scss b/app/assets/stylesheets/page_bundles/new_namespace.scss
index 60aa3c8f29f..189f010bdb2 100644
--- a/app/assets/stylesheets/page_bundles/new_namespace.scss
+++ b/app/assets/stylesheets/page_bundles/new_namespace.scss
@@ -8,10 +8,11 @@ $new-namespace-panel-height: 240px;
}
.new-namespace-panel-wrapper {
- @include media-breakpoint-down(md) {
+ width: 50%;
+
+ @include media-breakpoint-down(lg) {
width: 100%;
}
- width: 50%;
}
.new-namespace-panel {
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index 1081dd8f6d8..7b54be5c91f 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -62,6 +62,12 @@
.pipeline-tags .label-container {
white-space: normal;
}
+
+ .dark-mode-override {
+ .gl-dark & {
+ background-color: $white;
+ }
+ }
}
// Mini Pipelines
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 98074f8af29..d233adbf3d2 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -87,6 +87,20 @@
width: 145px;
}
+ .empty-state--agent {
+ .text-content {
+ @include gl-max-w-full;
+ @include media-breakpoint-up(lg) {
+ max-width: 70%;
+ }
+ }
+
+ .gl-alert-actions {
+ @include gl-mt-0;
+ @include gl-flex-wrap;
+ }
+ }
+
.top-area .nav-controls > .btn.btn-add-cluster {
margin-right: 0;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index a114a1dc82d..5173aeb824e 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -183,6 +183,8 @@
}
.commit-nav-buttons {
+ margin: 0 0.5rem;
+
a.btn,
button {
// See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/730
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 1bc6dfbd84a..ee97e8af296 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -175,7 +175,8 @@
}
}
- .block {
+ .block,
+ .issuable-sidebar-header {
@include clearfix;
padding: $gl-padding 0;
border-bottom: 1px solid $border-gray-normal;
@@ -184,11 +185,6 @@
width: $gutter-inner-width;
// --
- &.issuable-sidebar-header {
- padding-top: 0;
- padding-bottom: 10px;
- }
-
&:last-child {
border: 0;
}
@@ -273,10 +269,6 @@
padding: 0 20px;
}
- .issuable-sidebar-header {
- padding-top: 10px;
- }
-
&:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header {
display: none;
@@ -302,7 +294,6 @@
}
.gutter-toggle {
- margin-top: 7px;
border-left: 1px solid $border-gray-normal;
text-align: center;
}
@@ -331,20 +322,21 @@
width: $gutter-collapsed-width;
padding: 0;
- .block {
+ .block,
+ .issuable-sidebar-header {
width: $gutter-collapsed-width - 2px;
padding: 0;
border-bottom: 0;
overflow: hidden;
+ }
+ .block,
+ .gutter-toggle,
+ .sidebar-collapsed-container {
&.with-sub-blocks .sub-block:hover,
&:not(.with-sub-blocks):hover {
background-color: $gray-100;
}
-
- &.issuable-sidebar-header {
- padding-top: 0;
- }
}
.participants {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 1abaff40bc9..8807ab5e597 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -87,6 +87,10 @@ $tabs-holder-z-index: 250;
border: 1px solid $border-color;
border-radius: $border-radius-default;
background: var(--white, $white);
+
+ > .mr-widget-border-top:first-of-type {
+ border-top: 0;
+ }
}
.mr-widget-body,
@@ -702,7 +706,7 @@ $tabs-holder-z-index: 250;
.mr-version-dropdown,
.mr-version-compare-dropdown {
- margin: 0 7px;
+ margin: 0 0.5rem;
}
.dropdown-title {
@@ -711,7 +715,7 @@ $tabs-holder-z-index: 250;
// Shortening button height by 1px to make compare-versions
// header 56px and fit into our 8px design grid
- button {
+ .btn {
height: 34px;
}
@@ -885,7 +889,7 @@ $tabs-holder-z-index: 250;
.media-body {
min-width: 0;
font-size: 12px;
- margin-left: 40px;
+ margin-left: 32px;
}
&:not(:last-child) {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 01739c7eb3e..4a866489806 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -763,6 +763,7 @@ $system-note-svg-size: 16px;
.note-button.add-diff-note {
@include btn-comment-icon;
opacity: 0;
+ will-change: opacity;
&[disabled] {
background: $white;
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index ad040f65f3c..d38c1818f53 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -54,6 +54,8 @@
white-space: pre;
word-wrap: normal;
border-left: $border-style;
+ text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%; /* stylelint-disable-line property-no-vendor-prefix */
}
code {
@@ -65,7 +67,7 @@
}
.line-numbers {
- padding: 10px;
+ padding: 10px 10px 10px 0;
text-align: right;
float: left;
@@ -86,18 +88,24 @@
}
}
+ .file-actions {
+ flex-shrink: 0;
+ }
+
.file-title-flex-parent {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
background-color: $gray-light;
border: $border-style;
border-bottom: 0;
- padding: $gl-padding-top $gl-padding;
+ padding: $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
.file-header-content {
+ max-width: 75%;
+
.file-title-name {
font-weight: $gl-font-weight-bold;
}
@@ -105,6 +113,7 @@
.gitlab-embedded-snippets-title {
text-decoration: none;
color: $gl-text-color;
+ word-break: break-word;
&:hover {
text-decoration: underline;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 00a6ee579d8..a497f56f3b8 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -13,6 +13,10 @@ body.gl-dark {
--orange-400: #ab6100;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
+ --black: #fff;
+}
+.nav-sidebar li.active {
+ box-shadow: none;
}
:root {
--white: #333;
@@ -145,10 +149,6 @@ h1 {
color: transparent;
text-shadow: 0 0 0 #fafafa;
}
-.form-control::-ms-input-placeholder {
- color: #bfbfbf;
- opacity: 1;
-}
.form-control::placeholder {
color: #bfbfbf;
opacity: 1;
@@ -175,7 +175,6 @@ h1 {
color: #fafafa;
text-align: center;
vertical-align: middle;
- -moz-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
@@ -459,8 +458,7 @@ a {
border-top: 1px solid #404040;
}
.toggle-sidebar-button .collapse-text,
-.toggle-sidebar-button .icon-chevron-double-lg-left,
-.toggle-sidebar-button .icon-chevron-double-lg-right {
+.toggle-sidebar-button .icon-chevron-double-lg-left {
color: #999;
}
svg {
@@ -546,7 +544,7 @@ body {
max-width: 500px;
margin-top: 4px;
margin-bottom: 24px;
- font-size: 14px;
+ font-size: 0.875rem;
font-weight: 400;
padding: 8px 0;
background-color: #333;
@@ -626,9 +624,6 @@ input {
border-radius: 4px;
padding: 6px 10px;
}
-.form-control::-ms-input-placeholder {
- color: #868686;
-}
.form-control::placeholder {
color: #868686;
}
@@ -933,6 +928,7 @@ input {
.context-header .sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
+ color: #fafafa;
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
@@ -951,20 +947,14 @@ input {
}
.nav-sidebar {
position: fixed;
+ bottom: 0;
+ left: 0;
z-index: 600;
width: 220px;
top: 40px;
- bottom: 0;
- left: 0;
background-color: #303030;
- box-shadow: inset -1px 0 0 #404040;
transform: translate3d(0, 0, 0);
}
-@media (min-width: 576px) and (max-width: 576px) {
- .nav-sidebar:not(.sidebar-collapsed-desktop) {
- box-shadow: inset -1px 0 0 #404040, 2px 1px 3px rgba(0, 0, 0, 0.1);
- }
-}
.nav-sidebar.sidebar-collapsed-desktop {
width: 48px;
}
@@ -972,7 +962,8 @@ input {
overflow-x: hidden;
}
.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge),
-.nav-sidebar.sidebar-collapsed-desktop .nav-item-name {
+.nav-sidebar.sidebar-collapsed-desktop .nav-item-name,
+.nav-sidebar.sidebar-collapsed-desktop .collapse-text {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -984,479 +975,29 @@ input {
width: 1px;
}
.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items > li > a {
- min-height: 45px;
-}
-.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item {
- display: block;
-}
-.nav-sidebar.sidebar-collapsed-desktop .avatar-container {
- margin: 0 auto;
-}
-.nav-sidebar a {
- text-decoration: none;
-}
-.nav-sidebar ul {
- padding-left: 0;
- list-style: none;
-}
-.nav-sidebar li {
- white-space: nowrap;
-}
-.nav-sidebar li a {
- display: flex;
- align-items: center;
- padding: 12px 16px;
- color: #999;
-}
-.nav-sidebar li .nav-item-name {
- flex: 1;
-}
-.nav-sidebar li.active > a {
- font-weight: 600;
-}
-@media (max-width: 767.98px) {
- .nav-sidebar {
- left: -220px;
- }
-}
-.nav-sidebar .nav-icon-container {
- display: flex;
- margin-right: 8px;
-}
-.nav-sidebar .fly-out-top-item {
- display: none;
-}
-.nav-sidebar svg {
- height: 16px;
- width: 16px;
-}
-@media (min-width: 768px) and (max-width: 1199px) {
- .nav-sidebar:not(.sidebar-expanded-mobile) {
- width: 48px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
- overflow-x: hidden;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .badge.badge-pill:not(.fly-out-badge),
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a {
- min-height: 45px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item {
- display: block;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container {
- margin: 0 auto;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
- height: 60px;
- width: 48px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
- padding: 10px 4px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-top-level-items
- > li
- .sidebar-sub-level-items:not(.flyout-list) {
- display: none;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container {
- margin-right: 0;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
- padding: 16px;
- width: 47px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .collapse-text,
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .icon-chevron-double-lg-left {
- display: none;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .icon-chevron-double-lg-right {
- display: block;
- margin: 0;
- }
-}
-.nav-sidebar-inner-scroll {
- height: 100%;
- width: 100%;
- overflow: auto;
-}
-.sidebar-sub-level-items {
- display: none;
- padding-bottom: 8px;
-}
-.sidebar-sub-level-items > li a {
- padding: 8px 16px 8px 40px;
-}
-.sidebar-sub-level-items > li.active a {
- background: rgba(255, 255, 255, 0.04);
-}
-.sidebar-top-level-items {
- margin-bottom: 60px;
-}
-@media (min-width: 576px) {
- .sidebar-top-level-items > li > a {
- margin-right: 1px;
- }
-}
-.sidebar-top-level-items > li .badge.badge-pill {
- background-color: rgba(255, 255, 255, 0.08);
- color: #999;
-}
-.sidebar-top-level-items > li.active {
- background: rgba(255, 255, 255, 0.04);
-}
-.sidebar-top-level-items > li.active > a {
- margin-left: 4px;
- padding-left: 12px;
-}
-.sidebar-top-level-items > li.active .badge.badge-pill {
- font-weight: 600;
-}
-.sidebar-top-level-items
- > li.active
- .sidebar-sub-level-items:not(.is-fly-out-only) {
- display: block;
-}
-.toggle-sidebar-button,
-.close-nav-button {
- height: 48px;
- padding: 0 16px;
- background-color: #303030;
- border: 0;
- color: #999;
- display: flex;
- align-items: center;
-}
-.toggle-sidebar-button,
-.close-nav-button {
- position: fixed;
- bottom: 0;
- width: 219px;
- border-top: 1px solid #404040;
-}
-.toggle-sidebar-button svg,
-.close-nav-button svg {
- margin-right: 8px;
-}
-.toggle-sidebar-button .icon-chevron-double-lg-right,
-.close-nav-button .icon-chevron-double-lg-right {
- display: none;
-}
-.collapse-text {
- white-space: nowrap;
- overflow: hidden;
-}
-.sidebar-collapsed-desktop .context-header {
- height: 60px;
- width: 48px;
-}
-.sidebar-collapsed-desktop .context-header a {
- padding: 10px 4px;
-}
-.sidebar-collapsed-desktop .sidebar-context-title {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
-}
-.sidebar-collapsed-desktop
- .sidebar-top-level-items
- > li
- .sidebar-sub-level-items:not(.flyout-list) {
- display: none;
-}
-.sidebar-collapsed-desktop .nav-icon-container {
- margin-right: 0;
-}
-.sidebar-collapsed-desktop .toggle-sidebar-button {
- padding: 16px;
- width: 47px;
-}
-.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text,
-.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left {
- display: none;
-}
-.sidebar-collapsed-desktop
- .toggle-sidebar-button
- .icon-chevron-double-lg-right {
- display: block;
- margin: 0;
-}
-.fly-out-top-item > a {
- display: flex;
-}
-.fly-out-top-item .fly-out-badge {
- margin-left: 8px;
-}
-.fly-out-top-item-name {
- flex: 1;
-}
-.close-nav-button {
- display: none;
-}
-@media (max-width: 767.98px) {
- .close-nav-button {
- display: flex;
- }
- .toggle-sidebar-button {
- display: none;
- }
-}
-body.sidebar-refactoring.gl-dark .nav-sidebar li.active {
- box-shadow: none;
-}
-body.sidebar-refactoring.gl-dark .nav-sidebar .sidebar-sub-level-items {
- box-shadow: none;
- border: 1px solid #404040;
-}
-body.sidebar-refactoring.gl-dark
- .sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- color: #333;
-}
-body.sidebar-refactoring.gl-dark .nav-sidebar li a,
-body.sidebar-refactoring.gl-dark .toggle-sidebar-button .collapse-text,
-body.sidebar-refactoring.gl-dark
- .toggle-sidebar-button
- .icon-chevron-double-lg-left,
-body.sidebar-refactoring.gl-dark
- .toggle-sidebar-button
- .icon-chevron-double-lg-right,
-body.sidebar-refactoring.gl-dark
- .sidebar-top-level-items
- .context-header
- a
- .sidebar-context-title,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .sidebar-context-title,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item.active
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- .fly-out-top-item-container {
- color: #c4c4c4;
-}
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item.active
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- .fly-out-top-item-container {
- margin-top: 0;
-}
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item.active
- a,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item
- .fly-out-top-item-container {
- background: #333;
- color: #c4c4c4;
-}
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item
- a::before,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item.active
- a::before,
-body.sidebar-refactoring.gl-dark
- .nav-sidebar
- a:not(.has-sub-items)
- + .sidebar-sub-level-items
- .fly-out-top-item
- .fly-out-top-item-container::before {
- border-right-color: #333;
-}
-body.sidebar-refactoring.gl-dark .nav-sidebar .sidebar-sub-level-items {
- background-color: #333;
-}
-body.sidebar-refactoring.ui-indigo
- .nav-sidebar
- li.active:not(.fly-out-top-item)
- > a {
- color: #2f2a6b;
-}
-body.sidebar-refactoring.ui-indigo
- .nav-sidebar
- li.active
- .nav-icon-container
- svg {
- fill: #2f2a6b;
-}
-body.sidebar-refactoring .nav-sidebar {
- box-shadow: none;
-}
-body.sidebar-refactoring .nav-sidebar li.active {
- background-color: transparent;
- box-shadow: none !important;
-}
-@media (min-width: 768px) {
- body.sidebar-refactoring .page-with-contextual-sidebar {
- padding-left: 48px;
- }
-}
-@media (min-width: 1200px) {
- body.sidebar-refactoring .page-with-contextual-sidebar {
- padding-left: 220px;
- }
-}
-@media (min-width: 768px) {
- body.sidebar-refactoring .page-with-icon-sidebar {
- padding-left: 48px;
- }
-}
-body.sidebar-refactoring .nav-sidebar {
- position: fixed;
- bottom: 0;
- left: 0;
- z-index: 600;
- width: 220px;
- top: 40px;
- background-color: #303030;
- transform: translate3d(0, 0, 0);
-}
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop {
- width: 48px;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .nav-sidebar-inner-scroll {
- overflow-x: hidden;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .badge.badge-pill:not(.fly-out-badge),
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .nav-item-name,
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .collapse-text {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .sidebar-top-level-items
- > li
- > a {
min-height: unset;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .fly-out-top-item:not(.divider) {
+.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item:not(.divider) {
display: block !important;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .avatar-container {
+.nav-sidebar.sidebar-collapsed-desktop .avatar-container {
margin: 0 auto;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- li.active:not(.fly-out-top-item)
- > a {
+.nav-sidebar.sidebar-collapsed-desktop li.active:not(.fly-out-top-item) > a {
background-color: rgba(41, 41, 97, 0.08);
}
-body.sidebar-refactoring .nav-sidebar a {
+.nav-sidebar a {
text-decoration: none;
- color: #2f2a6b;
+ color: #fafafa;
}
-body.sidebar-refactoring .nav-sidebar li {
+.nav-sidebar li {
white-space: nowrap;
}
-body.sidebar-refactoring .nav-sidebar li .nav-item-name {
+.nav-sidebar li .nav-item-name {
flex: 1;
}
-body.sidebar-refactoring .nav-sidebar li > a,
-body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container {
+.nav-sidebar li > a,
+.nav-sidebar li > .fly-out-top-item-container {
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
@@ -1468,49 +1009,42 @@ body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container {
line-height: 1rem;
margin: 1px 4px;
}
-body.sidebar-refactoring .nav-sidebar li.active > a {
+.nav-sidebar li.active > a {
font-weight: 600;
}
-body.sidebar-refactoring
- .nav-sidebar
- li.active:not(.fly-out-top-item)
- > a:not(.has-sub-items) {
+.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
background-color: rgba(41, 41, 97, 0.08);
}
-body.sidebar-refactoring .nav-sidebar ul {
+.nav-sidebar ul {
padding-left: 0;
list-style: none;
}
@media (max-width: 767.98px) {
- body.sidebar-refactoring .nav-sidebar {
+ .nav-sidebar {
left: -220px;
}
}
-body.sidebar-refactoring .nav-sidebar .nav-icon-container {
+.nav-sidebar .nav-icon-container {
display: flex;
margin-right: 8px;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item {
display: none;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1522,30 +1056,26 @@ body.sidebar-refactoring
cursor: default;
pointer-events: none;
font-size: 0.75rem;
- background-color: #2f2a6b;
- color: #333;
margin-top: -0.25rem;
margin-bottom: -0.25rem;
margin-top: 0;
position: relative;
- background-color: #fff;
+ color: #333;
+ background: var(--black, #fff);
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a
strong,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a
strong,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1553,20 +1083,17 @@ body.sidebar-refactoring
strong {
font-weight: 400;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a::before,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a::before,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1582,34 +1109,18 @@ body.sidebar-refactoring
border-top: 0.25rem solid transparent;
border-bottom: 0.25rem solid transparent;
border-right: 0.25rem solid #fff;
+ border-right-color: var(--black, #fff);
}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items {
- margin-top: -0.25rem;
-}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item {
+.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item {
display: none;
}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
+.nav-sidebar
a.has-sub-items
+ .sidebar-sub-level-items
.fly-out-top-item.active
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a.has-sub-items
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1621,30 +1132,21 @@ body.sidebar-refactoring
cursor: default;
pointer-events: none;
font-size: 0.75rem;
- background-color: #2f2a6b;
- color: #333;
margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@media (min-width: 768px) and (max-width: 1199px) {
- body.sidebar-refactoring .nav-sidebar:not(.sidebar-expanded-mobile) {
+ .nav-sidebar:not(.sidebar-expanded-mobile) {
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-sidebar-inner-scroll {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
overflow-x: hidden;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.badge.badge-pill:not(.fly-out-badge),
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-item-name,
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .collapse-text {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name,
+ .nav-sidebar:not(.sidebar-expanded-mobile) .collapse-text {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1655,44 +1157,28 @@ body.sidebar-refactoring
white-space: nowrap;
width: 1px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-top-level-items
- > li
- > a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a {
min-height: unset;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .fly-out-top-item:not(.divider) {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item:not(.divider) {
display: block !important;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .avatar-container {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container {
margin: 0 auto;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
li.active:not(.fly-out-top-item)
> a {
background-color: rgba(41, 41, 97, 0.08);
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: 60px;
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header
- a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 10px 4px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-context-title {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1703,58 +1189,45 @@ body.sidebar-refactoring
white-space: nowrap;
width: 1px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: auto;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header
- a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 0.25rem;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.sidebar-top-level-items
> li
.sidebar-sub-level-items:not(.flyout-list) {
display: none;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-icon-container {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container {
margin-right: 0;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
.collapse-text {
display: none;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
.icon-chevron-double-lg-left {
transform: rotate(180deg);
- display: block;
margin: 0;
}
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll {
+.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
overflow: auto;
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header {
+.nav-sidebar-inner-scroll > div.context-header {
margin-top: 0.25rem;
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a {
+.nav-sidebar-inner-scroll > div.context-header a {
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
@@ -1769,78 +1242,46 @@ body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a {
margin-bottom: 0.25rem;
margin-top: 0;
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container {
+.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar {
+.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
border-style: none;
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
+.nav-sidebar-inner-scroll
> div.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .sidebar-context-title {
- color: #2f2a6b;
-}
-body.sidebar-refactoring .sidebar-top-level-items {
+.sidebar-top-level-items {
margin-top: 0.25rem;
margin-bottom: 60px;
}
-body.sidebar-refactoring .sidebar-top-level-items .context-header a {
+.sidebar-top-level-items .context-header a {
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .avatar-container {
+.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar {
+.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
border-style: none;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .sidebar-context-title {
- color: #2f2a6b;
-}
-body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill {
+.sidebar-top-level-items > li .badge.badge-pill {
border-radius: 0.5rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
@@ -1849,29 +1290,25 @@ body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill {
background-color: #064787;
color: #9dc7f1;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
> li.active
.sidebar-sub-level-items:not(.is-fly-out-only) {
display: block;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- > li.active
- .badge.badge-pill {
+.sidebar-top-level-items > li.active .badge.badge-pill {
font-weight: 400;
color: #9dc7f1;
}
-body.sidebar-refactoring .sidebar-sub-level-items {
+.sidebar-sub-level-items {
padding-top: 0;
padding-bottom: 0;
display: none;
}
-body.sidebar-refactoring .sidebar-sub-level-items:not(.fly-out-list) li > a {
+.sidebar-sub-level-items:not(.fly-out-list) li > a {
padding-left: 2.25rem;
}
-body.sidebar-refactoring .toggle-sidebar-button,
-body.sidebar-refactoring .close-nav-button {
+.toggle-sidebar-button,
+.close-nav-button {
height: 48px;
padding: 0 16px;
background-color: #303030;
@@ -1881,31 +1318,28 @@ body.sidebar-refactoring .close-nav-button {
align-items: center;
background-color: #303030;
border-top: 1px solid #404040;
- color: #2f2a6b;
position: fixed;
bottom: 0;
width: 220px;
}
-body.sidebar-refactoring .toggle-sidebar-button .collapse-text,
-body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-left,
-body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-right,
-body.sidebar-refactoring .close-nav-button .collapse-text,
-body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-left,
-body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-right {
+.toggle-sidebar-button .collapse-text,
+.toggle-sidebar-button .icon-chevron-double-lg-left,
+.close-nav-button .collapse-text,
+.close-nav-button .icon-chevron-double-lg-left {
color: inherit;
}
-body.sidebar-refactoring .collapse-text {
+.collapse-text {
white-space: nowrap;
overflow: hidden;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header {
+.sidebar-collapsed-desktop .context-header {
height: 60px;
width: 48px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a {
+.sidebar-collapsed-desktop .context-header a {
padding: 10px 4px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title {
+.sidebar-collapsed-desktop .sidebar-context-title {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1916,47 +1350,39 @@ body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title {
white-space: nowrap;
width: 1px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header {
+.sidebar-collapsed-desktop .context-header {
height: auto;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a {
+.sidebar-collapsed-desktop .context-header a {
padding: 0.25rem;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
+.sidebar-collapsed-desktop
.sidebar-top-level-items
> li
.sidebar-sub-level-items:not(.flyout-list) {
display: none;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .nav-icon-container {
+.sidebar-collapsed-desktop .nav-icon-container {
margin-right: 0;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .toggle-sidebar-button {
+.sidebar-collapsed-desktop .toggle-sidebar-button {
width: 48px;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
- .toggle-sidebar-button
- .collapse-text {
+.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text {
display: none;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
- .toggle-sidebar-button
- .icon-chevron-double-lg-left {
+.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left {
transform: rotate(180deg);
- display: block;
margin: 0;
}
-body.sidebar-refactoring .close-nav-button {
+.close-nav-button {
display: none;
}
@media (max-width: 767.98px) {
- body.sidebar-refactoring .close-nav-button {
+ .close-nav-button {
display: flex;
}
- body.sidebar-refactoring .toggle-sidebar-button {
+ .toggle-sidebar-button {
display: none;
}
}
@@ -2037,7 +1463,6 @@ svg.s16 {
top: 4px;
}
.search .search-input-wrap .search-icon {
- -moz-user-select: none;
user-select: none;
}
.search .search-input-wrap .clear-icon {
@@ -2066,7 +1491,7 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid #333;
+ border: 1px solid rgba(255, 255, 255, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -2086,12 +1511,6 @@ svg.s16 {
height: 32px;
margin-right: 8px;
}
-.avatar.s40,
-.avatar-container.s40 {
- width: 40px;
- height: 40px;
- margin-right: 8px;
-}
.avatar {
transition-property: none;
width: 40px;
@@ -2108,8 +1527,8 @@ svg.s16 {
.identicon {
text-align: center;
vertical-align: top;
- color: #525252;
- background-color: #eee;
+ color: #fafafa;
+ background-color: #303030;
}
.identicon.s16 {
font-size: 10px;
@@ -2119,30 +1538,26 @@ svg.s16 {
font-size: 14px;
line-height: 32px;
}
-.identicon.s40 {
- font-size: 16px;
- line-height: 38px;
-}
.identicon.bg1 {
- background-color: #ffebee;
+ background-color: #660e00;
}
.identicon.bg2 {
- background-color: #f3e5f5;
+ background-color: #f4f0ff;
}
.identicon.bg3 {
- background-color: #e8eaf6;
+ background-color: #f1f1ff;
}
.identicon.bg4 {
- background-color: #e3f2fd;
+ background-color: #033464;
}
.identicon.bg5 {
- background-color: #e0f2f1;
+ background-color: #0a4020;
}
.identicon.bg6 {
- background-color: #fbe9e7;
+ background-color: #5c2900;
}
.identicon.bg7 {
- background-color: #eee;
+ background-color: #303030;
}
.avatar-container {
overflow: hidden;
@@ -2162,10 +1577,6 @@ svg.s16 {
margin: 0;
align-self: center;
}
-.avatar-container.s40 {
- min-width: 40px;
- min-height: 40px;
-}
.rect-avatar {
border-radius: 2px;
}
@@ -2176,23 +1587,18 @@ svg.s16 {
border-radius: 2px;
}
.rect-avatar.s32,
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
+.nav-sidebar-inner-scroll
> div.context-header
a
.avatar-container.rect-avatar
.avatar.s32,
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
border-radius: 4px;
}
-.rect-avatar.s40 {
- border-radius: 4px;
-}
body.gl-dark .navbar-gitlab {
background-color: #fafafa;
}
@@ -2253,9 +1659,6 @@ body.gl-dark
body.gl-dark .search form {
background-color: rgba(250, 250, 250, 0.2);
}
-body.gl-dark .search .search-input::-ms-input-placeholder {
- color: rgba(250, 250, 250, 0.8);
-}
body.gl-dark .search .search-input::placeholder {
color: rgba(250, 250, 250, 0.8);
}
@@ -2263,17 +1666,14 @@ body.gl-dark .search .search-input-wrap .search-icon,
body.gl-dark .search .search-input-wrap .clear-icon {
fill: rgba(250, 250, 250, 0.8);
}
-body.gl-dark .nav-sidebar li.active {
- box-shadow: inset 4px 0 0 #999;
-}
body.gl-dark .nav-sidebar li.active > a {
color: #f0f0f0;
}
-body.gl-dark .nav-sidebar li.active .nav-icon-container svg {
- fill: #f0f0f0;
-}
-body.gl-dark .sidebar-top-level-items > li.active .badge.badge-pill {
- color: #f0f0f0;
+body.gl-dark .nav-sidebar .fly-out-top-item a,
+body.gl-dark .nav-sidebar .fly-out-top-item.active a,
+body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container {
+ background-color: #2f2a6b;
+ color: var(--black, #333);
}
body.gl-dark .logo-text svg {
fill: var(--gl-text-color);
@@ -2373,6 +1773,9 @@ body.gl-dark {
--black: #fff;
--svg-status-bg: #333;
}
+.nav-sidebar li.active {
+ box-shadow: none;
+}
.tab-width-8 {
-moz-tab-size: 8;
tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 4605b6de563..76d10300307 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -130,10 +130,6 @@ h1 {
color: transparent;
text-shadow: 0 0 0 #303030;
}
-.form-control::-ms-input-placeholder {
- color: #5e5e5e;
- opacity: 1;
-}
.form-control::placeholder {
color: #5e5e5e;
opacity: 1;
@@ -160,7 +156,6 @@ h1 {
color: #303030;
text-align: center;
vertical-align: middle;
- -moz-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
@@ -444,8 +439,7 @@ a {
border-top: 1px solid #dbdbdb;
}
.toggle-sidebar-button .collapse-text,
-.toggle-sidebar-button .icon-chevron-double-lg-left,
-.toggle-sidebar-button .icon-chevron-double-lg-right {
+.toggle-sidebar-button .icon-chevron-double-lg-left {
color: #666;
}
svg {
@@ -531,7 +525,7 @@ body {
max-width: 500px;
margin-top: 4px;
margin-bottom: 24px;
- font-size: 14px;
+ font-size: 0.875rem;
font-weight: 400;
padding: 8px 0;
background-color: #fff;
@@ -611,9 +605,6 @@ input {
border-radius: 4px;
padding: 6px 10px;
}
-.form-control::-ms-input-placeholder {
- color: #868686;
-}
.form-control::placeholder {
color: #868686;
}
@@ -918,6 +909,7 @@ input {
.context-header .sidebar-context-title {
overflow: hidden;
text-overflow: ellipsis;
+ color: #303030;
}
@media (min-width: 768px) {
.page-with-contextual-sidebar {
@@ -936,20 +928,14 @@ input {
}
.nav-sidebar {
position: fixed;
+ bottom: 0;
+ left: 0;
z-index: 600;
width: 220px;
top: 40px;
- bottom: 0;
- left: 0;
- background-color: #fafafa;
- box-shadow: inset -1px 0 0 #dbdbdb;
+ background-color: #f0f0f0;
transform: translate3d(0, 0, 0);
}
-@media (min-width: 576px) and (max-width: 576px) {
- .nav-sidebar:not(.sidebar-collapsed-desktop) {
- box-shadow: inset -1px 0 0 #dbdbdb, 2px 1px 3px rgba(0, 0, 0, 0.1);
- }
-}
.nav-sidebar.sidebar-collapsed-desktop {
width: 48px;
}
@@ -957,7 +943,8 @@ input {
overflow-x: hidden;
}
.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge),
-.nav-sidebar.sidebar-collapsed-desktop .nav-item-name {
+.nav-sidebar.sidebar-collapsed-desktop .nav-item-name,
+.nav-sidebar.sidebar-collapsed-desktop .collapse-text {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -969,362 +956,29 @@ input {
width: 1px;
}
.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items > li > a {
- min-height: 45px;
-}
-.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item {
- display: block;
-}
-.nav-sidebar.sidebar-collapsed-desktop .avatar-container {
- margin: 0 auto;
-}
-.nav-sidebar a {
- text-decoration: none;
-}
-.nav-sidebar ul {
- padding-left: 0;
- list-style: none;
-}
-.nav-sidebar li {
- white-space: nowrap;
-}
-.nav-sidebar li a {
- display: flex;
- align-items: center;
- padding: 12px 16px;
- color: #666;
-}
-.nav-sidebar li .nav-item-name {
- flex: 1;
-}
-.nav-sidebar li.active > a {
- font-weight: 600;
-}
-@media (max-width: 767.98px) {
- .nav-sidebar {
- left: -220px;
- }
-}
-.nav-sidebar .nav-icon-container {
- display: flex;
- margin-right: 8px;
-}
-.nav-sidebar .fly-out-top-item {
- display: none;
-}
-.nav-sidebar svg {
- height: 16px;
- width: 16px;
-}
-@media (min-width: 768px) and (max-width: 1199px) {
- .nav-sidebar:not(.sidebar-expanded-mobile) {
- width: 48px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
- overflow-x: hidden;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .badge.badge-pill:not(.fly-out-badge),
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a {
- min-height: 45px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item {
- display: block;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container {
- margin: 0 auto;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
- height: 60px;
- width: 48px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
- padding: 10px 4px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-top-level-items
- > li
- .sidebar-sub-level-items:not(.flyout-list) {
- display: none;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container {
- margin-right: 0;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
- padding: 16px;
- width: 47px;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .collapse-text,
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .icon-chevron-double-lg-left {
- display: none;
- }
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button
- .icon-chevron-double-lg-right {
- display: block;
- margin: 0;
- }
-}
-.nav-sidebar-inner-scroll {
- height: 100%;
- width: 100%;
- overflow: auto;
-}
-.sidebar-sub-level-items {
- display: none;
- padding-bottom: 8px;
-}
-.sidebar-sub-level-items > li a {
- padding: 8px 16px 8px 40px;
-}
-.sidebar-sub-level-items > li.active a {
- background: rgba(0, 0, 0, 0.04);
-}
-.sidebar-top-level-items {
- margin-bottom: 60px;
-}
-@media (min-width: 576px) {
- .sidebar-top-level-items > li > a {
- margin-right: 1px;
- }
-}
-.sidebar-top-level-items > li .badge.badge-pill {
- background-color: rgba(0, 0, 0, 0.08);
- color: #666;
-}
-.sidebar-top-level-items > li.active {
- background: rgba(0, 0, 0, 0.04);
-}
-.sidebar-top-level-items > li.active > a {
- margin-left: 4px;
- padding-left: 12px;
-}
-.sidebar-top-level-items > li.active .badge.badge-pill {
- font-weight: 600;
-}
-.sidebar-top-level-items
- > li.active
- .sidebar-sub-level-items:not(.is-fly-out-only) {
- display: block;
-}
-.toggle-sidebar-button,
-.close-nav-button {
- height: 48px;
- padding: 0 16px;
- background-color: #fafafa;
- border: 0;
- color: #666;
- display: flex;
- align-items: center;
-}
-.toggle-sidebar-button,
-.close-nav-button {
- position: fixed;
- bottom: 0;
- width: 219px;
- border-top: 1px solid #dbdbdb;
-}
-.toggle-sidebar-button svg,
-.close-nav-button svg {
- margin-right: 8px;
-}
-.toggle-sidebar-button .icon-chevron-double-lg-right,
-.close-nav-button .icon-chevron-double-lg-right {
- display: none;
-}
-.collapse-text {
- white-space: nowrap;
- overflow: hidden;
-}
-.sidebar-collapsed-desktop .context-header {
- height: 60px;
- width: 48px;
-}
-.sidebar-collapsed-desktop .context-header a {
- padding: 10px 4px;
-}
-.sidebar-collapsed-desktop .sidebar-context-title {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
-}
-.sidebar-collapsed-desktop
- .sidebar-top-level-items
- > li
- .sidebar-sub-level-items:not(.flyout-list) {
- display: none;
-}
-.sidebar-collapsed-desktop .nav-icon-container {
- margin-right: 0;
-}
-.sidebar-collapsed-desktop .toggle-sidebar-button {
- padding: 16px;
- width: 47px;
-}
-.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text,
-.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left {
- display: none;
-}
-.sidebar-collapsed-desktop
- .toggle-sidebar-button
- .icon-chevron-double-lg-right {
- display: block;
- margin: 0;
-}
-.fly-out-top-item > a {
- display: flex;
-}
-.fly-out-top-item .fly-out-badge {
- margin-left: 8px;
-}
-.fly-out-top-item-name {
- flex: 1;
-}
-.close-nav-button {
- display: none;
-}
-@media (max-width: 767.98px) {
- .close-nav-button {
- display: flex;
- }
- .toggle-sidebar-button {
- display: none;
- }
-}
-body.sidebar-refactoring.ui-indigo
- .nav-sidebar
- li.active:not(.fly-out-top-item)
- > a {
- color: #2f2a6b;
-}
-body.sidebar-refactoring.ui-indigo
- .nav-sidebar
- li.active
- .nav-icon-container
- svg {
- fill: #2f2a6b;
-}
-body.sidebar-refactoring .nav-sidebar {
- box-shadow: none;
-}
-body.sidebar-refactoring .nav-sidebar li.active {
- background-color: transparent;
- box-shadow: none !important;
-}
-@media (min-width: 768px) {
- body.sidebar-refactoring .page-with-contextual-sidebar {
- padding-left: 48px;
- }
-}
-@media (min-width: 1200px) {
- body.sidebar-refactoring .page-with-contextual-sidebar {
- padding-left: 220px;
- }
-}
-@media (min-width: 768px) {
- body.sidebar-refactoring .page-with-icon-sidebar {
- padding-left: 48px;
- }
-}
-body.sidebar-refactoring .nav-sidebar {
- position: fixed;
- bottom: 0;
- left: 0;
- z-index: 600;
- width: 220px;
- top: 40px;
- background-color: #f0f0f0;
- transform: translate3d(0, 0, 0);
-}
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop {
- width: 48px;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .nav-sidebar-inner-scroll {
- overflow-x: hidden;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .badge.badge-pill:not(.fly-out-badge),
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .nav-item-name,
-body.sidebar-refactoring .nav-sidebar.sidebar-collapsed-desktop .collapse-text {
- border: 0;
- clip: rect(0, 0, 0, 0);
- height: 1px;
- margin: -1px;
- overflow: hidden;
- padding: 0;
- position: absolute;
- white-space: nowrap;
- width: 1px;
-}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .sidebar-top-level-items
- > li
- > a {
min-height: unset;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .fly-out-top-item:not(.divider) {
+.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item:not(.divider) {
display: block !important;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- .avatar-container {
+.nav-sidebar.sidebar-collapsed-desktop .avatar-container {
margin: 0 auto;
}
-body.sidebar-refactoring
- .nav-sidebar.sidebar-collapsed-desktop
- li.active:not(.fly-out-top-item)
- > a {
+.nav-sidebar.sidebar-collapsed-desktop li.active:not(.fly-out-top-item) > a {
background-color: rgba(41, 41, 97, 0.08);
}
-body.sidebar-refactoring .nav-sidebar a {
+.nav-sidebar a {
text-decoration: none;
- color: #2f2a6b;
+ color: #303030;
}
-body.sidebar-refactoring .nav-sidebar li {
+.nav-sidebar li {
white-space: nowrap;
}
-body.sidebar-refactoring .nav-sidebar li .nav-item-name {
+.nav-sidebar li .nav-item-name {
flex: 1;
}
-body.sidebar-refactoring .nav-sidebar li > a,
-body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container {
+.nav-sidebar li > a,
+.nav-sidebar li > .fly-out-top-item-container {
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
@@ -1336,49 +990,42 @@ body.sidebar-refactoring .nav-sidebar li > .fly-out-top-item-container {
line-height: 1rem;
margin: 1px 4px;
}
-body.sidebar-refactoring .nav-sidebar li.active > a {
+.nav-sidebar li.active > a {
font-weight: 600;
}
-body.sidebar-refactoring
- .nav-sidebar
- li.active:not(.fly-out-top-item)
- > a:not(.has-sub-items) {
+.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
background-color: rgba(41, 41, 97, 0.08);
}
-body.sidebar-refactoring .nav-sidebar ul {
+.nav-sidebar ul {
padding-left: 0;
list-style: none;
}
@media (max-width: 767.98px) {
- body.sidebar-refactoring .nav-sidebar {
+ .nav-sidebar {
left: -220px;
}
}
-body.sidebar-refactoring .nav-sidebar .nav-icon-container {
+.nav-sidebar .nav-icon-container {
display: flex;
margin-right: 8px;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item {
display: none;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1390,30 +1037,26 @@ body.sidebar-refactoring
cursor: default;
pointer-events: none;
font-size: 0.75rem;
- background-color: #2f2a6b;
- color: #fff;
margin-top: -0.25rem;
margin-bottom: -0.25rem;
margin-top: 0;
position: relative;
- background-color: #000;
+ color: #fff;
+ background: var(--black, #000);
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a
strong,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a
strong,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1421,20 +1064,17 @@ body.sidebar-refactoring
strong {
font-weight: 400;
}
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
a::before,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item.active
a::before,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a:not(.has-sub-items)
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1450,34 +1090,18 @@ body.sidebar-refactoring
border-top: 0.25rem solid transparent;
border-bottom: 0.25rem solid transparent;
border-right: 0.25rem solid #000;
+ border-right-color: var(--black, #000);
}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items {
- margin-top: -0.25rem;
-}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item {
+.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item {
display: none;
}
-body.sidebar-refactoring
- .nav-sidebar
- a.has-sub-items
- + .sidebar-sub-level-items
- .fly-out-top-item
- a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a,
+.nav-sidebar
a.has-sub-items
+ .sidebar-sub-level-items
.fly-out-top-item.active
a,
-body.sidebar-refactoring
- .nav-sidebar
+.nav-sidebar
a.has-sub-items
+ .sidebar-sub-level-items
.fly-out-top-item
@@ -1489,30 +1113,21 @@ body.sidebar-refactoring
cursor: default;
pointer-events: none;
font-size: 0.75rem;
- background-color: #2f2a6b;
- color: #fff;
margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
@media (min-width: 768px) and (max-width: 1199px) {
- body.sidebar-refactoring .nav-sidebar:not(.sidebar-expanded-mobile) {
+ .nav-sidebar:not(.sidebar-expanded-mobile) {
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-sidebar-inner-scroll {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll {
overflow-x: hidden;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.badge.badge-pill:not(.fly-out-badge),
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-item-name,
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .collapse-text {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name,
+ .nav-sidebar:not(.sidebar-expanded-mobile) .collapse-text {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1523,44 +1138,28 @@ body.sidebar-refactoring
white-space: nowrap;
width: 1px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-top-level-items
- > li
- > a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a {
min-height: unset;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .fly-out-top-item:not(.divider) {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item:not(.divider) {
display: block !important;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .avatar-container {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container {
margin: 0 auto;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
li.active:not(.fly-out-top-item)
> a {
background-color: rgba(41, 41, 97, 0.08);
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: 60px;
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header
- a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 10px 4px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .sidebar-context-title {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1571,58 +1170,45 @@ body.sidebar-refactoring
white-space: nowrap;
width: 1px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header {
height: auto;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .context-header
- a {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a {
padding: 0.25rem;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.sidebar-top-level-items
> li
.sidebar-sub-level-items:not(.flyout-list) {
display: none;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .nav-icon-container {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container {
margin-right: 0;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
- .toggle-sidebar-button {
+ .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button {
width: 48px;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
.collapse-text {
display: none;
}
- body.sidebar-refactoring
- .nav-sidebar:not(.sidebar-expanded-mobile)
+ .nav-sidebar:not(.sidebar-expanded-mobile)
.toggle-sidebar-button
.icon-chevron-double-lg-left {
transform: rotate(180deg);
- display: block;
margin: 0;
}
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll {
+.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
overflow: auto;
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header {
+.nav-sidebar-inner-scroll > div.context-header {
margin-top: 0.25rem;
}
-body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a {
+.nav-sidebar-inner-scroll > div.context-header a {
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
@@ -1637,78 +1223,46 @@ body.sidebar-refactoring .nav-sidebar-inner-scroll > div.context-header a {
margin-bottom: 0.25rem;
margin-top: 0;
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container {
+.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar {
+.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
border-style: none;
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
+.nav-sidebar-inner-scroll
> div.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
- > div.context-header
- a
- .sidebar-context-title {
- color: #2f2a6b;
-}
-body.sidebar-refactoring .sidebar-top-level-items {
+.sidebar-top-level-items {
margin-top: 0.25rem;
margin-bottom: 60px;
}
-body.sidebar-refactoring .sidebar-top-level-items .context-header a {
+.sidebar-top-level-items .context-header a {
padding: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .avatar-container {
+.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar {
+.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
border-style: none;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- .context-header
- a
- .sidebar-context-title {
- color: #2f2a6b;
-}
-body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill {
+.sidebar-top-level-items > li .badge.badge-pill {
border-radius: 0.5rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
@@ -1717,29 +1271,25 @@ body.sidebar-refactoring .sidebar-top-level-items > li .badge.badge-pill {
background-color: #cbe2f9;
color: #0b5cad;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
> li.active
.sidebar-sub-level-items:not(.is-fly-out-only) {
display: block;
}
-body.sidebar-refactoring
- .sidebar-top-level-items
- > li.active
- .badge.badge-pill {
+.sidebar-top-level-items > li.active .badge.badge-pill {
font-weight: 400;
color: #0b5cad;
}
-body.sidebar-refactoring .sidebar-sub-level-items {
+.sidebar-sub-level-items {
padding-top: 0;
padding-bottom: 0;
display: none;
}
-body.sidebar-refactoring .sidebar-sub-level-items:not(.fly-out-list) li > a {
+.sidebar-sub-level-items:not(.fly-out-list) li > a {
padding-left: 2.25rem;
}
-body.sidebar-refactoring .toggle-sidebar-button,
-body.sidebar-refactoring .close-nav-button {
+.toggle-sidebar-button,
+.close-nav-button {
height: 48px;
padding: 0 16px;
background-color: #fafafa;
@@ -1749,31 +1299,28 @@ body.sidebar-refactoring .close-nav-button {
align-items: center;
background-color: #f0f0f0;
border-top: 1px solid #dbdbdb;
- color: #2f2a6b;
position: fixed;
bottom: 0;
width: 220px;
}
-body.sidebar-refactoring .toggle-sidebar-button .collapse-text,
-body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-left,
-body.sidebar-refactoring .toggle-sidebar-button .icon-chevron-double-lg-right,
-body.sidebar-refactoring .close-nav-button .collapse-text,
-body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-left,
-body.sidebar-refactoring .close-nav-button .icon-chevron-double-lg-right {
+.toggle-sidebar-button .collapse-text,
+.toggle-sidebar-button .icon-chevron-double-lg-left,
+.close-nav-button .collapse-text,
+.close-nav-button .icon-chevron-double-lg-left {
color: inherit;
}
-body.sidebar-refactoring .collapse-text {
+.collapse-text {
white-space: nowrap;
overflow: hidden;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header {
+.sidebar-collapsed-desktop .context-header {
height: 60px;
width: 48px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a {
+.sidebar-collapsed-desktop .context-header a {
padding: 10px 4px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title {
+.sidebar-collapsed-desktop .sidebar-context-title {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
@@ -1784,47 +1331,39 @@ body.sidebar-refactoring .sidebar-collapsed-desktop .sidebar-context-title {
white-space: nowrap;
width: 1px;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header {
+.sidebar-collapsed-desktop .context-header {
height: auto;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .context-header a {
+.sidebar-collapsed-desktop .context-header a {
padding: 0.25rem;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
+.sidebar-collapsed-desktop
.sidebar-top-level-items
> li
.sidebar-sub-level-items:not(.flyout-list) {
display: none;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .nav-icon-container {
+.sidebar-collapsed-desktop .nav-icon-container {
margin-right: 0;
}
-body.sidebar-refactoring .sidebar-collapsed-desktop .toggle-sidebar-button {
+.sidebar-collapsed-desktop .toggle-sidebar-button {
width: 48px;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
- .toggle-sidebar-button
- .collapse-text {
+.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text {
display: none;
}
-body.sidebar-refactoring
- .sidebar-collapsed-desktop
- .toggle-sidebar-button
- .icon-chevron-double-lg-left {
+.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left {
transform: rotate(180deg);
- display: block;
margin: 0;
}
-body.sidebar-refactoring .close-nav-button {
+.close-nav-button {
display: none;
}
@media (max-width: 767.98px) {
- body.sidebar-refactoring .close-nav-button {
+ .close-nav-button {
display: flex;
}
- body.sidebar-refactoring .toggle-sidebar-button {
+ .toggle-sidebar-button {
display: none;
}
}
@@ -1905,7 +1444,6 @@ svg.s16 {
top: 4px;
}
.search .search-input-wrap .search-icon {
- -moz-user-select: none;
user-select: none;
}
.search .search-input-wrap .clear-icon {
@@ -1934,7 +1472,7 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid #f5f5f5;
+ border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1954,12 +1492,6 @@ svg.s16 {
height: 32px;
margin-right: 8px;
}
-.avatar.s40,
-.avatar-container.s40 {
- width: 40px;
- height: 40px;
- margin-right: 8px;
-}
.avatar {
transition-property: none;
width: 40px;
@@ -1976,8 +1508,8 @@ svg.s16 {
.identicon {
text-align: center;
vertical-align: top;
- color: #525252;
- background-color: #eee;
+ color: #303030;
+ background-color: #f0f0f0;
}
.identicon.s16 {
font-size: 10px;
@@ -1987,30 +1519,26 @@ svg.s16 {
font-size: 14px;
line-height: 32px;
}
-.identicon.s40 {
- font-size: 16px;
- line-height: 38px;
-}
.identicon.bg1 {
- background-color: #ffebee;
+ background-color: #fcf1ef;
}
.identicon.bg2 {
- background-color: #f3e5f5;
+ background-color: #f4f0ff;
}
.identicon.bg3 {
- background-color: #e8eaf6;
+ background-color: #f1f1ff;
}
.identicon.bg4 {
- background-color: #e3f2fd;
+ background-color: #e9f3fc;
}
.identicon.bg5 {
- background-color: #e0f2f1;
+ background-color: #ecf4ee;
}
.identicon.bg6 {
- background-color: #fbe9e7;
+ background-color: #fdf1dd;
}
.identicon.bg7 {
- background-color: #eee;
+ background-color: #f0f0f0;
}
.avatar-container {
overflow: hidden;
@@ -2030,10 +1558,6 @@ svg.s16 {
margin: 0;
align-self: center;
}
-.avatar-container.s40 {
- min-width: 40px;
- min-height: 40px;
-}
.rect-avatar {
border-radius: 2px;
}
@@ -2044,23 +1568,18 @@ svg.s16 {
border-radius: 2px;
}
.rect-avatar.s32,
-body.sidebar-refactoring
- .nav-sidebar-inner-scroll
+.nav-sidebar-inner-scroll
> div.context-header
a
.avatar-container.rect-avatar
.avatar.s32,
-body.sidebar-refactoring
- .sidebar-top-level-items
+.sidebar-top-level-items
.context-header
a
.avatar-container.rect-avatar
.avatar.s32 {
border-radius: 4px;
}
-.rect-avatar.s40 {
- border-radius: 4px;
-}
.tab-width-8 {
-moz-tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 81a87742850..070ab36e0b3 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -198,10 +198,6 @@ hr {
color: transparent;
text-shadow: 0 0 0 #303030;
}
-.form-control::-ms-input-placeholder {
- color: #5e5e5e;
- opacity: 1;
-}
.form-control::placeholder {
color: #5e5e5e;
opacity: 1;
@@ -229,7 +225,6 @@ hr {
color: #303030;
text-align: center;
vertical-align: middle;
- -moz-user-select: none;
user-select: none;
background-color: transparent;
border: 1px solid transparent;
@@ -294,11 +289,6 @@ fieldset:disabled a.btn {
.mb-3 {
margin-bottom: 1rem !important;
}
-@media (min-width: 576px) {
- .mt-sm-0 {
- margin-top: 0 !important;
- }
-}
.text-center {
text-align: center !important;
}
@@ -324,13 +314,6 @@ fieldset:disabled a.btn {
appearance: none;
-moz-appearance: none;
}
-.gl-form-input:not(.form-control-plaintext):-moz-read-only,
-.gl-form-input.form-control:not(.form-control-plaintext):-moz-read-only {
- background-color: #fafafa;
- color: #868686;
- box-shadow: inset 0 0 0 1px #dbdbdb;
- cursor: not-allowed;
-}
.gl-form-input:disabled,
.gl-form-input:not(.form-control-plaintext):read-only,
.gl-form-input.form-control:disabled,
@@ -340,10 +323,6 @@ fieldset:disabled a.btn {
box-shadow: inset 0 0 0 1px #dbdbdb;
cursor: not-allowed;
}
-.gl-form-input::-ms-input-placeholder,
-.gl-form-input.form-control::-ms-input-placeholder {
- color: #868686;
-}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
color: #868686;
@@ -500,7 +479,6 @@ hr {
z-index: 1;
}
.flash-container.sticky {
- position: -webkit-sticky;
position: sticky;
top: 48px;
z-index: 251;
@@ -526,9 +504,6 @@ label.label-bold {
border-radius: 4px;
padding: 6px 10px;
}
-.form-control::-ms-input-placeholder {
- color: #868686;
-}
.form-control::placeholder {
color: #868686;
}
@@ -542,7 +517,7 @@ label.label-bold {
justify-content: center;
height: 40px;
background: #fff;
- border-bottom: 1px solid #f0f0f0;
+ border-bottom: 1px solid #dbdbdb;
}
.navbar-empty .tanuki-logo,
.navbar-empty .brand-header-logo {
@@ -796,9 +771,15 @@ svg {
.gl-display-flex {
display: flex;
}
+.gl-display-block {
+ display: block;
+}
.gl-align-items-center {
align-items: center;
}
+.gl-w-full {
+ width: 100%;
+}
.gl-p-2 {
padding: 0.25rem;
}
@@ -817,6 +798,11 @@ svg {
.gl-mb-5 {
margin-bottom: 1rem;
}
+@media (min-width: 36rem) {
+ .gl-sm-mt-0 {
+ margin-top: 0;
+ }
+}
.gl-text-left {
text-align: left;
}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 9d98fe5c739..ea7aaaa8ec8 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -253,3 +253,14 @@ $well-inner-border: $gray-200;
color: $gray-900;
border-color: $gray-800;
}
+
+.nav-sidebar {
+ li.active {
+ box-shadow: none;
+ }
+
+ .sidebar-sub-level-items.fly-out-list {
+ box-shadow: none;
+ border: 1px solid $border-color;
+ }
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 6a60978b954..a94169ab494 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -174,20 +174,20 @@
}
// Sidebar
- .nav-sidebar li.active {
- box-shadow: inset 4px 0 0 $border-and-box-shadow;
-
- > a {
- color: $sidebar-text;
- }
-
- .nav-icon-container svg {
- fill: $sidebar-text;
- }
+ .nav-sidebar li.active > a {
+ color: $sidebar-text;
}
- .sidebar-top-level-items > li.active .badge.badge-pill {
- color: $sidebar-text;
+ .nav-sidebar {
+ .fly-out-top-item {
+ a,
+ a:hover,
+ &.active a,
+ .fly-out-top-item-container {
+ background-color: $purple-900;
+ color: var(--black, $white);
+ }
+ }
}
.nav-links li {
@@ -213,7 +213,6 @@
.ide-sidebar-link {
&.active {
color: $border-and-box-shadow;
- box-shadow: inset 3px 0 $border-and-box-shadow;
&.is-right {
box-shadow: inset -3px 0 $border-and-box-shadow;
diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss
index bbf14afcca2..9566c9c6004 100644
--- a/app/assets/stylesheets/themes/theme_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_indigo.scss
@@ -6,7 +6,7 @@ body {
$indigo-200,
$indigo-500,
$indigo-700,
- $indigo-800,
+ $purple-900,
$indigo-900,
$white
);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index cabbe5834cb..10334d771b8 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -65,6 +65,8 @@
min-width: 0;
}
+// .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466
+.gl-font-size-inherit,
.font-size-inherit { font-size: inherit; }
.gl-w-8 { width: px-to-rem($grid-size); }
.gl-w-16 { width: px-to-rem($grid-size * 2); }
@@ -85,6 +87,12 @@
padding-bottom: $gl-spacing-scale-8;
}
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1495
+.gl-py-13 {
+ padding-top: $gl-spacing-scale-13;
+ padding-bottom: $gl-spacing-scale-13;
+}
+
.gl-transition-property-stroke-opacity {
transition-property: stroke-opacity;
}
@@ -117,6 +125,25 @@
}
}
+// Will be moved to @gitlab/ui (without the !important) in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1462
+// We only need the bang (!) version until the non-bang version is added to
+// @gitlab/ui utitlities.scss. Once there, it will get loaded in the correct
+// order to properly override `.gl-mt-6` which is used for narrower screen
+// widths (currently that style gets added to the application.css stylesheet
+// after this one, so it takes precedence).
+.gl-md-mt-11\! {
+ @media (min-width: $breakpoint-md) {
+ margin-top: $gl-spacing-scale-11 !important;
+ }
+}
+
+// Same as above (also without the !important) but for overriding `.gl-pt-6`
+.gl-md-pt-11\! {
+ @media (min-width: $breakpoint-md) {
+ padding-top: $gl-spacing-scale-11 !important;
+ }
+}
+
// This is used to help prevent issues with margin collapsing.
// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing.
.gl-force-block-formatting-context::after {
@@ -200,3 +227,23 @@ $gl-line-height-42: px-to-rem(42px);
.gl-max-h-none\! {
max-height: none !important;
}
+
+// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1465
+.gl-popover {
+ .popover-header {
+ .gl-button.close {
+ margin-top: -$gl-spacing-scale-3;
+ margin-right: -$gl-spacing-scale-4;
+ }
+ }
+}
+
+// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1490
+.gl-w-grid-size-28 {
+ width: $grid-size * 28;
+}
+
+// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1491
+.gl-min-w-8 {
+ min-width: $gl-spacing-scale-8;
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 7960e5d64d0..8039fac02ec 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -2,7 +2,7 @@
class Admin::ApplicationSettingsController < Admin::ApplicationController
include InternalRedirect
- include ServicesHelper
+ include IntegrationsHelper
# NOTE: Use @application_setting in this controller when you need to access
# application_settings after it has been modified. This is because the
@@ -27,7 +27,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
feature_category :source_code_management, [:repository, :clear_repository_check_states]
feature_category :continuous_integration, [:ci_cd, :reset_registration_token]
- feature_category :usage_ping, [:usage_data]
+ feature_category :service_ping, [:usage_data]
feature_category :integrations, [:integrations]
feature_category :pages, [:lets_encrypt_terms_of_service]
@@ -207,6 +207,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
params[:application_setting][:import_sources]&.delete("")
+ params[:application_setting][:valid_runner_registrars]&.delete("")
params[:application_setting][:restricted_visibility_levels]&.delete("")
if params[:application_setting].key?(:required_instance_ci_template)
@@ -245,7 +246,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
disabled_oauth_sign_in_sources: [],
import_sources: [],
restricted_visibility_levels: [],
- repository_storages_weighted: {}
+ repository_storages_weighted: {},
+ valid_runner_registrars: []
]
end
diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb
index c1dffbf423d..65b47308e4c 100644
--- a/app/controllers/admin/background_migrations_controller.rb
+++ b/app/controllers/admin/background_migrations_controller.rb
@@ -15,6 +15,20 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController
@successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id))
end
+ def pause
+ migration = batched_migration_class.find(params[:id])
+ migration.paused!
+
+ redirect_back fallback_location: { action: 'index' }
+ end
+
+ def resume
+ migration = batched_migration_class.find(params[:id])
+ migration.active!
+
+ redirect_back fallback_location: { action: 'index' }
+ end
+
private
def batched_migration_class
diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb
index f30ee37fa58..d4b7d750759 100644
--- a/app/controllers/admin/ci/variables_controller.rb
+++ b/app/controllers/admin/ci/variables_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::Ci::VariablesController < Admin::ApplicationController
- feature_category :continuous_integration
+ feature_category :pipeline_authoring
def show
respond_to do |format|
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index 8163f062b62..e750b5c5ad4 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::CohortsController < Admin::ApplicationController
- include Analytics::UniqueVisitsHelper
+ include RedisTracking
feature_category :devops_reports
@@ -21,8 +21,6 @@ class Admin::CohortsController < Admin::ApplicationController
end
def track_cohorts_visit
- if request.format.html? && request.headers['DNT'] != '1'
- track_visit('i_analytics_cohorts')
- end
+ track_unique_redis_hll_event('i_analytics_cohorts') if trackable_html_request?
end
end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index 316e6d9aa74..76c1c46e0e8 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -2,7 +2,7 @@
class Admin::IntegrationsController < Admin::ApplicationController
include IntegrationsActions
- include ServicesHelper
+ include IntegrationsHelper
before_action :not_found, unless: -> { instance_level_integrations? }
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index bf9cfa3acff..d1c91d9617f 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -8,7 +8,7 @@ class Admin::RunnersController < Admin::ApplicationController
push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
end
- feature_category :continuous_integration
+ feature_category :runner
NUMBER_OF_RUNNERS_PER_PAGE = 30
diff --git a/app/controllers/admin/usage_trends_controller.rb b/app/controllers/admin/usage_trends_controller.rb
index 7073f71a1a8..0b315517594 100644
--- a/app/controllers/admin/usage_trends_controller.rb
+++ b/app/controllers/admin/usage_trends_controller.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
class Admin::UsageTrendsController < Admin::ApplicationController
- include Analytics::UniqueVisitsHelper
+ include RedisTracking
- track_unique_visits :index, target_id: 'i_analytics_instance_statistics'
+ track_redis_hll_event :index, name: 'i_analytics_instance_statistics'
feature_category :devops_reports
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 07ecde1181f..34bad74a9fc 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -106,10 +106,6 @@ class ApplicationController < ActionController::Base
redirect_back(fallback_location: default, **options)
end
- def check_if_gl_com_or_dev
- render_404 unless ::Gitlab.dev_env_or_com?
- end
-
def not_found
render_404
end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 003ed45adb5..f0f074792ed 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -136,7 +136,7 @@ module Boards
def issue_params
params.require(:issue)
.permit(:title, :milestone_id, :project_id)
- .merge(board_id: params[:board_id], list_id: params[:list_id], request: request)
+ .merge(board_id: params[:board_id], list_id: params[:list_id])
end
def serializer
diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb
index 1cfcd2905f2..4e5af1945a4 100644
--- a/app/controllers/chaos_controller.rb
+++ b/app/controllers/chaos_controller.rb
@@ -31,7 +31,7 @@ class ChaosController < ActionController::Base
gc_stat = Gitlab::Chaos.run_gc
render json: {
- worker_id: Prometheus::PidProvider.worker_id,
+ worker_id: ::Prometheus::PidProvider.worker_id,
gc_stat: gc_stat
}
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 4f4b204def8..da5b7ccfbf0 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -151,14 +151,14 @@ module AuthenticatesWithTwoFactor
def handle_two_factor_failure(user, method, message)
user.increment_failed_attempts!
- log_failed_two_factor(user, method, request.remote_ip)
+ log_failed_two_factor(user, method)
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
flash.now[:alert] = message
prompt_for_two_factor(user)
end
- def log_failed_two_factor(user, method, ip_address)
+ def log_failed_two_factor(user, method)
# overridden in EE
end
diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
index a8155f1e639..574fc6c0f37 100644
--- a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb
@@ -98,7 +98,7 @@ module AuthenticatesWithTwoFactorForAdminMode
def admin_handle_two_factor_failure(user, method, message)
user.increment_failed_attempts!
- log_failed_two_factor(user, method, request.remote_ip)
+ log_failed_two_factor(user, method)
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
flash.now[:alert] = message
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 929e60a9e77..2664a7b7151 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -3,6 +3,7 @@
module IssuableActions
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
+ include Gitlab::Cache::Helpers
included do
before_action :authorize_destroy_issuable!, only: :destroy
@@ -129,7 +130,11 @@ module IssuableActions
discussions = Discussion.build_collection(notes, issuable)
- render json: discussion_serializer.represent(discussions, context: self)
+ if issuable.is_a?(MergeRequest) && Feature.enabled?(:merge_request_discussion_cache, issuable.target_project, default_enabled: :yaml)
+ render_cached(discussions, with: discussion_serializer, context: self)
+ else
+ render json: discussion_serializer.represent(discussions, context: self)
+ end
end
# rubocop:enable CodeReuse/ActiveRecord
diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
index e0e3f628cc5..65237b552ca 100644
--- a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
+++ b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
@@ -16,7 +16,7 @@ module Metrics::Dashboard::PrometheusApiProxy
return error_response(variable_substitution_result)
end
- prometheus_result = Prometheus::ProxyService.new(
+ prometheus_result = ::Prometheus::ProxyService.new(
proxyable,
proxy_method,
proxy_path,
diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb
index 3155208f47c..c1135d2f759 100644
--- a/app/controllers/concerns/redis_tracking.rb
+++ b/app/controllers/concerns/redis_tracking.rb
@@ -12,12 +12,13 @@
# You can also pass custom conditions using `if:`, using the same format as with Rails callbacks.
# You can also pass an optional block that calculates and returns a custom id to track.
module RedisTracking
+ include Gitlab::Tracking::Helpers
extend ActiveSupport::Concern
class_methods do
def track_redis_hll_event(*controller_actions, name:, if: nil, &block)
custom_conditions = Array.wrap(binding.local_variable_get('if'))
- conditions = [:trackable_request?, *custom_conditions]
+ conditions = [:trackable_html_request?, *custom_conditions]
after_action only: controller_actions, if: conditions do
track_unique_redis_hll_event(name, &block)
@@ -37,10 +38,6 @@ module RedisTracking
Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: unique_id)
end
- def trackable_request?
- request.format.html? && request.headers['DNT'] != '1'
- end
-
def visitor_id
return cookies[:visitor_id] if cookies[:visitor_id].present?
return unless current_user
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index 9e861d2859d..eb1223f22a9 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -47,31 +47,16 @@ module SpammableActions
end
end
- def spammable_params
- # NOTE: For the legacy reCAPTCHA implementation based on the HTML/HAML form, the
- # 'g-recaptcha-response' field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the
- # recaptcha gem, which is called from the HAML `_recaptcha_form.html.haml` form.
- #
- # It is used in the `Recaptcha::Verify#verify_recaptcha` to extract the value from `params`,
- # if the `response` option is not passed explicitly.
- #
- # Instead of relying on this behavior, we are extracting and passing it explicitly. This will
- # make it consistent with the newer, modern reCAPTCHA verification process as it will be
- # implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API,
- # which requires that the recaptcha response param be obtained and passed explicitly.
- #
- # It can also be expanded to multiple fields when we move to future alternative captcha
- # implementations such as FriendlyCaptcha. See https://gitlab.com/gitlab-org/gitlab/-/issues/273480
-
- # After this newer GraphQL/JS API process is fully supported by the backend, we can remove the
- # check for the 'g-recaptcha-response' field and other HTML/HAML form-specific support.
- captcha_response = params['g-recaptcha-response'] || params[:captcha_response]
-
- {
- request: request,
- spam_log_id: params[:spam_log_id],
- captcha_response: captcha_response
- }
+ # TODO: This method is currently only needed for issue create, to convert spam/CAPTCHA values from
+ # params, and instead be passed as headers, as the spam services now all expect. It can be removed
+ # when issue create is is converted to a client/JS based approach instead of the legacy HAML
+ # `_recaptcha_form.html.haml` which is rendered via the `projects/issues/verify` template.
+ # In that case, which is based on the legacy reCAPTCHA implementation using the HTML/HAML form,
+ # the 'g-recaptcha-response' field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the
+ # recaptcha gem, which is called from the HAML `_recaptcha_form.html.haml` form.
+ def extract_legacy_spam_params_to_headers
+ request.headers['X-GitLab-Captcha-Response'] = params['g-recaptcha-response'] || params[:captcha_response]
+ request.headers['X-GitLab-Spam-Log-Id'] = params[:spam_log_id]
end
def spammable
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index b7f6691ef4b..848b7ee44c5 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -14,8 +14,7 @@ module WikiActions
before_action { respond_to :html }
before_action :authorize_read_wiki!
- before_action :authorize_create_wiki!, only: [:edit, :create]
- before_action :authorize_admin_wiki!, only: :destroy
+ before_action :authorize_create_wiki!, only: [:edit, :create, :destroy]
before_action :wiki
before_action :page, only: [:show, :edit, :update, :history, :destroy, :diff]
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index e17b16c26a2..1369e82a69b 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -13,7 +13,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
@milestones = milestones.page(params[:page])
end
format.json do
- render json: milestones.to_json(only: [:id, :title], methods: :name)
+ render json: milestones.to_json(only: [:id, :title, :due_date], methods: :name)
end
end
end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 782c8c293fd..25ac0af9731 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -3,7 +3,6 @@
class Dashboard::TodosController < Dashboard::ApplicationController
include ActionView::Helpers::NumberHelper
include PaginatedCollection
- include Analytics::UniqueVisitsHelper
before_action :authorize_read_project!, only: :index
before_action :authorize_read_group!, only: :index
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index a3bbfc8be0d..f6c71ac8087 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -13,8 +13,16 @@ class Groups::ApplicationController < ApplicationController
before_action :set_sorting
requires_cross_project_access
+ helper_method :can_manage_members?
+
private
+ def can_manage_members?(group = @group)
+ strong_memoize(:can_manage_members) do
+ can?(current_user, :admin_group_member, group)
+ end
+ end
+
def group
@group ||= find_routable!(Group, params[:group_id] || params[:id])
end
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 3d8cdd766bf..04b4d8ea9a7 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -8,6 +8,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
+ push_frontend_feature_flag(:issue_boards_filtered_search, group, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, group, default_enabled: :yaml)
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 8d9059d271f..d5e7653dea2 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -22,8 +22,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
feature_category :authentication_and_authorization
- helper_method :can_manage_members?
-
def index
@sort = params[:sort].presence || sort_value_name
@@ -53,12 +51,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
private
- def can_manage_members?
- strong_memoize(:can_manage_members) do
- can?(current_user, :admin_group_member, @group)
- end
- end
-
def present_invited_members(invited_members)
present_members(invited_members
.page(params[:invited_members_page])
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index e9dce3947dd..63eff750d1b 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -15,7 +15,7 @@ class Groups::MilestonesController < Groups::ApplicationController
@milestones = milestones.page(params[:page])
end
format.json do
- render json: milestones.to_json(only: [:id, :title], methods: :name)
+ render json: milestones.to_json(only: [:id, :title, :due_date], methods: :name)
end
end
end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index b02b0e85d38..1cff658dd52 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -7,7 +7,7 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
- feature_category :continuous_integration
+ feature_category :runner
def show
end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 00ddb8d736c..9dbbd385ea8 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -6,7 +6,7 @@ module Groups
skip_cross_project_access_check :show, :update
- feature_category :continuous_integration
+ feature_category :pipeline_authoring
def show
respond_to do |format|
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 06906001ef0..a1fb74cf277 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -13,7 +13,7 @@ class HelpController < ApplicationController
def index
# Remove YAML frontmatter so that it doesn't look weird
- @help_index = File.read(Rails.root.join('doc', 'README.md')).sub(YAML_FRONT_MATTER_REGEXP, '')
+ @help_index = File.read(Rails.root.join('doc', 'index.md')).sub(YAML_FRONT_MATTER_REGEXP, '')
# Prefix Markdown links with `help/` unless they are external links.
# '//' not necessarily part of URL, e.g., mailto:mail@example.com
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index 9d6c0a003c4..e99b8cfa0c7 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -10,7 +10,7 @@ class Import::BulkImportsController < ApplicationController
POLLING_INTERVAL = 3_000
- rescue_from BulkImports::Clients::HTTP::ConnectionError, with: :bulk_import_connection_error
+ rescue_from BulkImports::Error, with: :bulk_import_connection_error
def configure
session[access_token_key] = configure_params[access_token_key]&.strip
@@ -87,7 +87,7 @@ class Import::BulkImportsController < ApplicationController
def client
@client ||= BulkImports::Clients::HTTP.new(
- uri: session[url_key],
+ url: session[url_key],
token: session[access_token_key],
per_page: params[:per_page],
page: params[:page]
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 0a9a9e03e94..e6aae144da6 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -86,18 +86,7 @@ class InvitesController < ApplicationController
if user_sign_up?
set_session_invite_params
- experiment(:invite_signup_page_interaction, actor: member) do |experiment_instance|
- set_originating_member_id if experiment_instance.enabled?
-
- experiment_instance.use do
- redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.")
- end
- experiment_instance.try do
- redirect_to new_users_sign_up_invite_path(invite_email: member.invite_email)
- end
-
- experiment_instance.track(:view)
- end
+ redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.")
else
redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice
end
@@ -106,11 +95,7 @@ class InvitesController < ApplicationController
def set_session_invite_params
session[:invite_email] = member.invite_email
- set_originating_member_id if Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type])
- end
-
- def set_originating_member_id
- session[:originating_member_id] = member.id
+ session[:originating_member_id] = member.id if Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type])
end
def sign_in_redirect_params
diff --git a/app/controllers/jira_connect/events_controller.rb b/app/controllers/jira_connect/events_controller.rb
index d833491b8f7..fe66e742c44 100644
--- a/app/controllers/jira_connect/events_controller.rb
+++ b/app/controllers/jira_connect/events_controller.rb
@@ -19,7 +19,7 @@ class JiraConnect::EventsController < JiraConnect::ApplicationController
end
def uninstalled
- if current_jira_installation.destroy
+ if JiraConnectInstallations::DestroyService.execute(current_jira_installation, jira_connect_base_path, jira_connect_events_uninstalled_path)
head :ok
else
head :unprocessable_entity
diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb
index 1ef1e12bb02..a0c307a0a03 100644
--- a/app/controllers/metrics_controller.rb
+++ b/app/controllers/metrics_controller.rb
@@ -30,7 +30,7 @@ class MetricsController < ActionController::Base
def system_metrics
Gitlab::Metrics::System.summary.merge(
- worker_id: Prometheus::PidProvider.worker_id
+ worker_id: ::Prometheus::PidProvider.worker_id
)
end
end
diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb
index 7f04927f517..9e16d195b00 100644
--- a/app/controllers/profiles/gpg_keys_controller.rb
+++ b/app/controllers/profiles/gpg_keys_controller.rb
@@ -22,7 +22,7 @@ class Profiles::GpgKeysController < Profiles::ApplicationController
end
def destroy
- @gpg_key.destroy
+ GpgKeys::DestroyService.new(current_user).execute(@gpg_key)
respond_to do |format|
format.html { redirect_to profile_gpg_keys_url, status: :found }
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index ba539ef808d..8dc9697c56d 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -9,7 +9,11 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def index
set_index_vars
- @personal_access_token = finder.build
+ scopes = params[:scopes].split(',').map(&:squish).select(&:present?).map(&:to_sym) unless params[:scopes].nil?
+ @personal_access_token = finder.build(
+ name: params[:name],
+ scopes: scopes
+ )
end
def create
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index f6a92b07295..7bb3ed1d109 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -15,7 +15,7 @@ class Projects::ArtifactsController < Projects::ApplicationController
MAX_PER_PAGE = 20
- feature_category :continuous_integration
+ feature_category :build_artifacts
def index
# Loading artifacts is very expensive in projects with a lot of artifacts.
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index c6c9237292d..08066acb45c 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -23,6 +23,10 @@ class Projects::BlobController < Projects::ApplicationController
# We need to assign the blob vars before `authorize_edit_tree!` so we can
# validate access to a specific ref.
before_action :assign_blob_vars
+
+ # Since BlobController doesn't use assign_ref_vars, we have to call this explicitly
+ before_action :rectify_renamed_default_branch!, only: [:show]
+
before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
before_action :commit, except: [:new, :create]
@@ -140,11 +144,15 @@ class Projects::BlobController < Projects::ApplicationController
end
def commit
- @commit = @repository.commit(@ref)
+ @commit ||= @repository.commit(@ref)
return render_404 unless @commit
end
+ def redirect_renamed_default_branch?
+ action_name == 'show'
+ end
+
def assign_blob_vars
@id = params[:id]
@ref, @path = extract_ref(@id)
@@ -152,6 +160,12 @@ class Projects::BlobController < Projects::ApplicationController
render_404
end
+ def rectify_renamed_default_branch!
+ @commit ||= @repository.commit(@ref)
+
+ super
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def after_edit_path
from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: params[:from_merge_request_iid])
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 43c9046f850..035b76abfd6 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
end
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
index 148080a71f4..d5655d40429 100644
--- a/app/controllers/projects/build_artifacts_controller.rb
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -8,7 +8,7 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
before_action :extract_ref_name_and_path
before_action :validate_artifacts!, except: [:download]
- feature_category :continuous_integration
+ feature_category :build_artifacts
def download
redirect_to download_project_job_artifacts_path(project, job, params: request.query_parameters)
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 3d2398f7ee3..6748be06ded 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -52,7 +52,8 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def pipelines
@pipelines = @commit.pipelines.order(id: :desc)
- @pipelines = @pipelines.where(ref: params[:ref]).page(params[:page]).per(30) if params[:ref]
+ @pipelines = @pipelines.where(ref: params[:ref]) if params[:ref]
+ @pipelines = @pipelines.page(params[:page])
respond_to do |format|
format.html
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index d1d27286c68..db5ba51ee01 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -4,15 +4,19 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
- include Analytics::UniqueVisitsHelper
include GracefulTimeoutHandling
+ include RedisTracking
before_action :authorize_read_cycle_analytics!
- track_unique_visits :show, target_id: 'p_analytics_valuestream'
+ track_redis_hll_event :show, name: 'p_analytics_valuestream'
feature_category :planning_analytics
+ before_action do
+ push_licensed_feature(:cycle_analytics_for_groups) if project.licensed_feature_available?(:cycle_analytics_for_groups)
+ end
+
def show
@cycle_analytics = Analytics::CycleAnalytics::ProjectLevel.new(project: @project, options: options(cycle_analytics_project_params))
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
index 97810d7d439..94fe67b5e85 100644
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -14,6 +14,6 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon
end
def proxy_variable_substitution_service
- Prometheus::ProxyVariableSubstitutionService
+ ::Prometheus::ProxyVariableSubstitutionService
end
end
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index a59824b1085..b99c233411a 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -13,10 +13,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action :ensure_flag_writable!, only: [:update]
before_action :exclude_legacy_flags_check, only: [:edit]
- before_action do
- push_frontend_feature_flag(:feature_flag_permissions)
- end
-
feature_category :feature_flags
def index
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 9e42d218ceb..0f00fda4687 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -17,7 +17,7 @@ class Projects::ForksController < Projects::ApplicationController
feature_category :source_code_management
before_action do
- push_frontend_feature_flag(:fork_project_form)
+ push_frontend_feature_flag(:fork_project_form, @project, default_enabled: :yaml)
end
def index
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index ad39b317b31..7a7961c28bb 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -2,14 +2,14 @@
class Projects::GraphsController < Projects::ApplicationController
include ExtractsPath
- include Analytics::UniqueVisitsHelper
+ include RedisTracking
# Authorize
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_read_repository_graphs!
- track_unique_visits :charts, target_id: 'p_analytics_repo'
+ track_redis_hll_event :charts, name: 'p_analytics_repo'
feature_category :source_code_management
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 8418a607659..46c4761b0ea 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -25,9 +25,9 @@ module Projects
false
end
- def jira_service
- strong_memoize(:jira_service) do
- @project.jira_service
+ def jira_integration
+ strong_memoize(:jira_integration) do
+ @project.jira_integration
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 295213bd38c..5d38e431c8a 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -50,10 +50,9 @@ class Projects::IssuesController < Projects::ApplicationController
end
before_action only: :show do
- real_time_feature_flag = :real_time_issue_sidebar
- real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
+ real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:real_time_issue_sidebar, @project)
- push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
+ push_to_gon_attributes(:features, :real_time_issue_sidebar, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml)
@@ -130,12 +129,14 @@ class Projects::IssuesController < Projects::ApplicationController
end
def create
- create_params = issue_params.merge(spammable_params).merge(
+ extract_legacy_spam_params_to_headers
+ create_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
discussion_to_resolve: params[:discussion_to_resolve]
)
- service = ::Issues::CreateService.new(project: project, current_user: current_user, params: create_params)
+ spam_params = ::Spam::SpamParams.new_from_request(request: request)
+ service = ::Issues::CreateService.new(project: project, current_user: current_user, params: create_params, spam_params: spam_params)
@issue = service.execute
create_vulnerability_issue_feedback(issue)
@@ -335,8 +336,8 @@ class Projects::IssuesController < Projects::ApplicationController
end
def update_service
- update_params = issue_params.merge(spammable_params)
- ::Issues::UpdateService.new(project: project, current_user: current_user, params: update_params)
+ spam_params = ::Spam::SpamParams.new_from_request(request: request)
+ ::Issues::UpdateService.new(project: project, current_user: current_user, params: issue_params, spam_params: spam_params)
end
def finder_type
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 92442fd4e28..49687a50ff6 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -17,6 +17,10 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_jobs_table_vue, only: [:index]
+ before_action do
+ push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml)
+ end
+
layout 'project'
feature_category :continuous_integration
diff --git a/app/controllers/projects/mattermosts_controller.rb b/app/controllers/projects/mattermosts_controller.rb
index ac204427885..ebba20b285a 100644
--- a/app/controllers/projects/mattermosts_controller.rb
+++ b/app/controllers/projects/mattermosts_controller.rb
@@ -7,7 +7,7 @@ class Projects::MattermostsController < Projects::ApplicationController
layout 'project_settings'
before_action :authorize_admin_project!
- before_action :service
+ before_action :integration
before_action :teams, only: [:new]
feature_category :integrations
@@ -16,11 +16,11 @@ class Projects::MattermostsController < Projects::ApplicationController
end
def create
- result, message = @service.configure(current_user, configure_params)
+ result, message = integration.configure(current_user, configure_params)
if result
flash[:notice] = 'This service is now configured'
- redirect_to edit_project_service_path(@project, service)
+ redirect_to edit_project_service_path(@project, integration)
else
flash[:alert] = message || 'Failed to configure service'
redirect_to new_project_mattermost_path(@project)
@@ -31,15 +31,15 @@ class Projects::MattermostsController < Projects::ApplicationController
def configure_params
params.require(:mattermost).permit(:trigger, :team_id).merge(
- url: service_trigger_url(@service),
+ url: service_trigger_url(integration),
icon_url: asset_url('slash-command-logo.png', skip_pipeline: true))
end
def teams
- @teams, @teams_error_message = @service.list_teams(current_user)
+ @teams, @teams_error_message = integration.list_teams(current_user)
end
- def service
- @service ||= @project.find_or_initialize_service('mattermost_slash_commands')
+ def integration
+ @integration ||= @project.find_or_initialize_integration('mattermost_slash_commands')
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index f125952cb97..88423bec915 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -3,6 +3,7 @@
class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
include DiffHelper
include RendersNotes
+ include Gitlab::Cache::Helpers
before_action :commit
before_action :define_diff_vars
@@ -40,7 +41,16 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
pagination_data: diffs.pagination_data
}
- render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
+ if diff_options_hash[:paths].blank? && Feature.enabled?(:diffs_batch_render_cached, project, default_enabled: :yaml)
+ render_cached(
+ diffs,
+ with: PaginatedDiffSerializer.new(current_user: current_user),
+ cache_context: -> (_) { [diff_view, params[:w], params[:expanded], params[:per_page], params[:page]] },
+ **options
+ )
+ else
+ render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
+ end
end
def diffs_metadata
@@ -193,7 +203,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def track_viewed_diffs_events
- return if request.headers['DNT'] == '1'
+ return if dnt_enabled?
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
.track_mr_diffs_action(merge_request: @merge_request)
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 5958c7f66e5..cfa64bbc16d 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -30,20 +30,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
push_frontend_feature_flag(:file_identifier_hash)
- push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:drag_comment_selection, @project, default_enabled: true)
- push_frontend_feature_flag(:unified_diff_components, @project, default_enabled: true)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
- push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true)
push_frontend_feature_flag(:local_file_reviews, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
- push_frontend_feature_flag(:codequality_mr_diff_annotations, project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
@@ -170,7 +165,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def pipelines
set_pipeline_variables
- @pipelines = @pipelines.page(params[:page]).per(30)
+ @pipelines = @pipelines.page(params[:page])
Gitlab::PollingInterval.set_header(response, interval: 10_000)
@@ -223,7 +218,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def codequality_mr_diff_reports
- reports_response(@merge_request.find_codequality_mr_diff_reports)
+ reports_response(@merge_request.find_codequality_mr_diff_reports, head_pipeline)
end
def codequality_reports
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index dcdda18784d..630e7ccd43f 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -33,7 +33,7 @@ class Projects::MilestonesController < Projects::ApplicationController
@milestones = @milestones.page(params[:page])
end
format.json do
- render json: @milestones.to_json(only: [:id, :title], methods: :name)
+ render json: @milestones.to_json(only: [:id, :title, :due_date], methods: :name)
end
end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 7779f3c3b65..b4196878c4f 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -2,7 +2,7 @@
class Projects::PipelinesController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize
- include Analytics::UniqueVisitsHelper
+ include RedisTracking
before_action :disable_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
@@ -14,7 +14,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:pipeline_graph_layers_view, project, type: :development, default_enabled: :yaml)
- push_frontend_feature_flag(:pipeline_filter_jobs, project, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:graphql_pipeline_details_users, current_user, type: :development, default_enabled: :yaml)
end
@@ -25,7 +24,7 @@ class Projects::PipelinesController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
- track_unique_visits :charts, target_id: 'p_analytics_pipelines'
+ track_redis_hll_event :charts, name: 'p_analytics_pipelines'
wrap_parameters Ci::Pipeline
@@ -43,13 +42,11 @@ class Projects::PipelinesController < Projects::ApplicationController
.new(project, current_user, index_params)
.execute
.page(params[:page])
- .per(20)
@pipelines_count = limited_pipelines_count(project)
respond_to do |format|
format.html do
- enable_pipeline_empty_state_templates_experiment
enable_code_quality_walkthrough_experiment
enable_ci_runner_templates_experiment
end
@@ -301,18 +298,6 @@ class Projects::PipelinesController < Projects::ApplicationController
params.permit(:scope, :username, :ref, :status)
end
- def enable_pipeline_empty_state_templates_experiment
- experiment(:pipeline_empty_state_templates, namespace: project.root_ancestor) do |e|
- e.exclude! unless current_user
- e.exclude! if @pipelines_count.to_i > 0
- e.exclude! if helpers.has_gitlab_ci?(project)
-
- e.control {}
- e.candidate {}
- e.record!
- end
- end
-
def enable_code_quality_walkthrough_experiment
experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e|
e.exclude! unless current_user
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
index d70d29a341f..f3a3d22244c 100644
--- a/app/controllers/projects/prometheus/metrics_controller.rb
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -66,7 +66,7 @@ module Projects
)
if @metric.persisted?
- redirect_to edit_project_service_path(project, ::PrometheusService),
+ redirect_to edit_project_service_path(project, ::Integrations::Prometheus),
notice: _('Metric was successfully added.')
else
render 'new'
@@ -77,7 +77,7 @@ module Projects
@metric = update_metrics_service(prometheus_metric).execute
if @metric.persisted?
- redirect_to edit_project_service_path(project, ::PrometheusService),
+ redirect_to edit_project_service_path(project, ::Integrations::Prometheus),
notice: _('Metric was successfully updated.')
else
render 'edit'
@@ -93,7 +93,7 @@ module Projects
respond_to do |format|
format.html do
- redirect_to edit_project_service_path(project, ::PrometheusService), status: :see_other
+ redirect_to edit_project_service_path(project, ::Integrations::Prometheus), status: :see_other
end
format.json do
head :ok
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index f01d10f4afa..be2abc5cddf 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -25,12 +25,6 @@ class Projects::ReleasesController < Projects::ApplicationController
end
end
- def new
- unless Feature.enabled?(:new_release_page, project, default_enabled: true)
- redirect_to(new_project_tag_path(@project))
- end
- end
-
def downloads
redirect_to link.url
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 8f64a8aa1d3..8beebb52980 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -117,7 +117,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
# from Redis.
def extract_ref_and_filename(id)
path = id.strip
- data = path.match(/(.*)\/(.*)/)
+ data = path.match(%r{(.*)/(.*)})
if data
[data[1], data[2]]
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index ec1f57f090a..e841c3e3d49 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -6,7 +6,7 @@ class Projects::RunnersController < Projects::ApplicationController
layout 'project_settings'
- feature_category :continuous_integration
+ feature_category :runner
def index
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
@@ -51,14 +51,14 @@ class Projects::RunnersController < Projects::ApplicationController
end
def toggle_shared_runners
- if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
- render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized
- return
- end
-
- project.toggle!(:shared_runners_enabled)
+ update_params = { shared_runners_enabled: !project.shared_runners_enabled }
+ result = Projects::UpdateService.new(project, current_user, update_params).execute
- render json: {}, status: :ok
+ if result[:status] == :success
+ render json: {}, status: :ok
+ else
+ render json: { error: result[:message] }, status: :unauthorized
+ end
end
def toggle_group_runners
diff --git a/app/controllers/projects/service_hook_logs_controller.rb b/app/controllers/projects/service_hook_logs_controller.rb
index 5c814ea139f..88de0b7ba0d 100644
--- a/app/controllers/projects/service_hook_logs_controller.rb
+++ b/app/controllers/projects/service_hook_logs_controller.rb
@@ -1,20 +1,23 @@
# frozen_string_literal: true
class Projects::ServiceHookLogsController < Projects::HookLogsController
- before_action :service, only: [:show, :retry]
+ extend Gitlab::Utils::Override
+
+ before_action :integration, only: [:show, :retry]
def retry
execute_hook
- redirect_to edit_project_service_path(@project, @service)
+ redirect_to edit_project_service_path(@project, @integration)
end
private
- def hook
- @hook ||= service.service_hook
+ def integration
+ @integration ||= @project.find_or_initialize_integration(params[:service_id])
end
- def service
- @service ||= @project.find_or_initialize_service(params[:service_id])
+ override :hook
+ def hook
+ @hook ||= integration.service_hook || not_found
end
end
diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/service_ping_controller.rb
index 77ee53f2e5d..00530c09be8 100644
--- a/app/controllers/projects/usage_ping_controller.rb
+++ b/app/controllers/projects/service_ping_controller.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
-class Projects::UsagePingController < Projects::ApplicationController
+class Projects::ServicePingController < Projects::ApplicationController
before_action :authenticate_user!
- feature_category :usage_ping
+ feature_category :service_ping
def web_ide_clientside_preview
return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index cad13d7e708..ef6d96e8737 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -9,8 +9,8 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :ensure_service_enabled
before_action :integration
before_action :web_hook_logs, only: [:edit, :update]
- before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
- before_action :redirect_deprecated_prometheus_service, only: [:update]
+ before_action :set_deprecation_notice_for_prometheus_integration, only: [:edit, :update]
+ before_action :redirect_deprecated_prometheus_integration, only: [:update]
respond_to :html
@@ -46,7 +46,7 @@ class Projects::ServicesController < Projects::ApplicationController
end
def test
- if integration.can_test?
+ if integration.testable?
render json: service_test_response, status: :ok
else
render json: {}, status: :not_found
@@ -84,7 +84,7 @@ class Projects::ServicesController < Projects::ApplicationController
end
def integration
- @integration ||= @project.find_or_initialize_service(params[:id])
+ @integration ||= @project.find_or_initialize_integration(params[:id])
end
alias_method :service, :integration
@@ -104,15 +104,15 @@ class Projects::ServicesController < Projects::ApplicationController
.merge(errors: integration.errors.as_json)
end
- def redirect_deprecated_prometheus_service
- redirect_to edit_project_service_path(project, integration) if integration.is_a?(::PrometheusService) && Feature.enabled?(:settings_operations_prometheus_service, project)
+ def redirect_deprecated_prometheus_integration
+ redirect_to edit_project_service_path(project, integration) if integration.is_a?(::Integrations::Prometheus) && Feature.enabled?(:settings_operations_prometheus_service, project)
end
- def set_deprecation_notice_for_prometheus_service
- return if !integration.is_a?(::PrometheusService) || !Feature.enabled?(:settings_operations_prometheus_service, project)
+ def set_deprecation_notice_for_prometheus_integration
+ return if !integration.is_a?(::Integrations::Prometheus) || !Feature.enabled?(:settings_operations_prometheus_service, project)
operations_link_start = "<a href=\"#{project_settings_operations_path(project)}\">"
- message = s_('PrometheusService|You can now manage your Prometheus settings on the %{operations_link_start}Operations%{operations_link_end} page. Fields on this page has been deprecated.') % { operations_link_start: operations_link_start, operations_link_end: "</a>" }
+ message = s_('PrometheusService|You can now manage your Prometheus settings on the %{operations_link_start}Operations%{operations_link_end} page. Fields on this page have been deprecated.') % { operations_link_start: operations_link_start, operations_link_end: "</a>" }
flash.now[:alert] = message.html_safe
end
end
diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb
index e3bb8c616df..1ecede4c7a2 100644
--- a/app/controllers/projects/settings/access_tokens_controller.rb
+++ b/app/controllers/projects/settings/access_tokens_controller.rb
@@ -50,13 +50,17 @@ module Projects
end
def create_params
- params.require(:project_access_token).permit(:name, :expires_at, scopes: [])
+ params.require(:project_access_token).permit(:name, :expires_at, :access_level, scopes: [])
end
def set_index_vars
+ # Loading project members so that we can fetch access level of the bot
+ # user in the project without multiple queries.
+ @project.project_members.load
+
@scopes = Gitlab::Auth.resource_bot_scopes
- @active_project_access_tokens = finder(state: 'active').execute
- @inactive_project_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute
+ @active_project_access_tokens = finder(state: 'active').execute.preload_users
+ @inactive_project_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute.preload_users
@new_project_access_token = PersonalAccessToken.redis_getdel(key_identity)
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 3254d4129d3..960c0beb244 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -12,6 +12,7 @@ module Projects
before_action :define_variables
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:ci_scoped_job_token, @project, default_enabled: :yaml)
end
helper_method :highlight_badge
diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb
index e54f4c511db..c9d92d1aee9 100644
--- a/app/controllers/projects/settings/integrations_controller.rb
+++ b/app/controllers/projects/settings/integrations_controller.rb
@@ -9,7 +9,7 @@ module Projects
feature_category :integrations
def show
- @integrations = @project.find_or_initialize_services
+ @integrations = @project.find_or_initialize_integrations
end
end
end
diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb
index fee51dc1311..8f0a19cfac5 100644
--- a/app/controllers/projects/settings/packages_and_registries_controller.rb
+++ b/app/controllers/projects/settings/packages_and_registries_controller.rb
@@ -16,7 +16,12 @@ module Projects
private
def packages_and_registries_settings_enabled!
- render_404 unless settings_packages_and_registries_enabled?(project)
+ render_404 unless can_destroy_container_registry_image?(project)
+ end
+
+ def can_destroy_container_registry_image?(project)
+ Gitlab.config.registry.enabled &&
+ can?(current_user, :destroy_container_image, project)
end
end
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index b5cfc3990b2..475c9de2503 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -39,6 +39,10 @@ class Projects::TreeController < Projects::ApplicationController
private
+ def redirect_renamed_default_branch?
+ action_name == 'show'
+ end
+
def assign_dir_vars
@branch_name = params[:branch_name]
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index d8efc1b7b54..f93c75a203e 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -3,7 +3,7 @@
class Projects::VariablesController < Projects::ApplicationController
before_action :authorize_admin_build!
- feature_category :continuous_integration
+ feature_category :pipeline_authoring
def show
respond_to do |format|
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 53d80b8be58..0dbf7d40f87 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -37,11 +37,7 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
- end
-
- before_action only: [:new] do
- # Run experiment before render so it will be written to the `gon` for FE
- helpers.new_repo_experiment_text
+ push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
end
layout :determine_layout
@@ -78,7 +74,6 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
- experiment(:new_repo, user: current_user).track(:project_created)
experiment(:new_project_readme, actor: current_user).track(
:created,
property: active_new_project_tab,
@@ -162,6 +157,7 @@ class ProjectsController < Projects::ApplicationController
format.atom do
load_events
+ @events = @events.select { |event| event.visible_to_user?(current_user) }
render layout: 'xml.atom'
end
end
@@ -378,8 +374,6 @@ class ProjectsController < Projects::ApplicationController
.new(projects, offset: params[:offset].to_i, filter: event_filter)
.to_a
.map(&:present)
-
- Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
index d04e8d296ed..3c94bce126c 100644
--- a/app/controllers/registrations/experience_levels_controller.rb
+++ b/app/controllers/registrations/experience_levels_controller.rb
@@ -2,7 +2,7 @@
module Registrations
class ExperienceLevelsController < ApplicationController
- layout 'signup_onboarding'
+ layout 'minimal'
before_action :ensure_namespace_path_param
diff --git a/app/controllers/registrations/invites_controller.rb b/app/controllers/registrations/invites_controller.rb
deleted file mode 100644
index 548714e80e9..00000000000
--- a/app/controllers/registrations/invites_controller.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module Registrations
- class InvitesController < RegistrationsController
- layout 'simple_registration'
-
- before_action :check_if_gl_com_or_dev
- end
-end
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 34283cc8db7..303ee431a4d 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -2,7 +2,7 @@
module Registrations
class WelcomeController < ApplicationController
- layout 'welcome'
+ layout 'minimal'
skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
before_action :require_current_user
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index cacd3c2da64..7b1060eba8f 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -199,7 +199,6 @@ class RegistrationsController < Devise::RegistrationsController
return unless member
- experiment(:invite_signup_page_interaction, actor: member).track(:form_submission)
experiment('members/invite_email', actor: member).track(:accepted)
end
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 11a219b4ff0..e51bfe6a37e 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -84,11 +84,7 @@ module Repositories
return if Feature.enabled?(:disable_git_http_fetch_writes)
- if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
- Projects::FetchStatisticsIncrementService.new(project).execute
- else
- ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
- end
+ Projects::FetchStatisticsIncrementService.new(project).execute
end
def access
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index ac6239615b4..4160b528301 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -16,6 +16,8 @@ class SearchController < ApplicationController
search_term_present && !params[:project_id].present?
end
+ rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
+
layout 'search'
feature_category :global_search
@@ -150,6 +152,15 @@ class SearchController < ApplicationController
redirect_to new_user_session_path, alert: _('You must be logged in to search across all of GitLab')
end
+
+ def render_timeout(exception)
+ raise exception unless action_name.to_sym == :show
+
+ log_exception(exception)
+
+ @timeout = true
+ render status: :request_timeout
+ end
end
SearchController.prepend_mod_with('SearchController')
diff --git a/app/controllers/users/unsubscribes_controller.rb b/app/controllers/users/unsubscribes_controller.rb
new file mode 100644
index 00000000000..9ac07083cd5
--- /dev/null
+++ b/app/controllers/users/unsubscribes_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Users
+ class UnsubscribesController < ApplicationController
+ skip_before_action :authenticate_user!
+
+ feature_category :users
+
+ def show
+ @user = get_user
+ end
+
+ def create
+ @user = get_user
+
+ if @user
+ @user.admin_unsubscribe!
+ Notify.send_unsubscribed_notification(@user.id).deliver_later
+ end
+
+ redirect_to new_user_session_path, notice: 'You have been unsubscribed'
+ end
+
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def get_user
+ @email = Base64.urlsafe_decode64(params[:email])
+ User.find_by(email: @email)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 53ea8ea2d3a..4ebf4a80498 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -10,45 +10,49 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
end
def publish(_result = nil)
- return unless should_track? # don't track events for excluded contexts
+ super
- record_experiment if @record # record the subject in the database if the context contains a namespace, group, project, actor or user
-
- track(:assignment) # track that we've assigned a variant for this context
-
- push_to_client
+ publish_to_client if should_track? # publish the experiment data to the client
+ publish_to_database if @record # publish the experiment context to the database
end
- # push the experiment data to the client
- def push_to_client
+ def publish_to_client
Gon.push({ experiment: { name => signature } }, true)
rescue NoMethodError
# means we're not in the request cycle, and can't add to Gon. Log a warning maybe?
end
- def track(action, **event_args)
- return unless should_track? # don't track events for excluded contexts
+ def publish_to_database
+ # if the context contains a namespace, group, project, user, or actor
+ value = context.value
+ subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor]
+ return unless ExperimentSubject.valid_subject?(subject)
- # track the event, and mix in the experiment signature data
- Gitlab::Tracking.event(name, action.to_s, **event_args.merge(
- context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
- 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', signature
- )
- ))
+ variant = :experimental if @variant_name != :control
+ Experiment.add_subject(name, variant: variant || :control, subject: subject)
end
def record!
@record = true
end
- def exclude!
- @excluded = true
- end
-
def control_behavior
# define a default nil control behavior so we can omit it when not needed
end
+ # TODO: remove
+ # This is deprecated logic as of v0.6.0 and should eventually be removed, but
+ # needs to stay intact for actively running experiments. The new strategy
+ # utilizes Digest::SHA2, a secret seed, and generates a 64-byte string.
+ def key_for(source, seed = name)
+ source = source.keys + source.values if source.is_a?(Hash)
+
+ ingredients = Array(source).map { |v| identify(v) }
+ ingredients.unshift(seed)
+
+ Digest::MD5.hexdigest(ingredients.join('|'))
+ end
+
private
def feature_flag_name
@@ -58,13 +62,4 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
def experiment_group?
Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml)
end
-
- def record_experiment
- subject = context.value[:namespace] || context.value[:group] || context.value[:project] || context.value[:user] || context.value[:actor]
- return unless ExperimentSubject.valid_subject?(subject)
-
- variant = :experimental if @variant_name != :control
-
- Experiment.add_subject(name, variant: variant || :control, subject: subject)
- end
end
diff --git a/app/experiments/new_project_readme_content_experiment.rb b/app/experiments/new_project_readme_content_experiment.rb
new file mode 100644
index 00000000000..f86803db093
--- /dev/null
+++ b/app/experiments/new_project_readme_content_experiment.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class NewProjectReadmeContentExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ TEMPLATE_PATH = Rails.root.join('app', 'experiments', 'templates', 'new_project_readme_content')
+ include Rails.application.routes.url_helpers
+
+ def run_with(project, variant: nil)
+ @project = project
+ record!
+ run(variant)
+ end
+
+ def control_behavior
+ template('readme_basic.md')
+ end
+
+ def advanced_behavior
+ template('readme_advanced.md')
+ end
+
+ def redirect(to_url)
+ experiment_redirect_url(self, to_url)
+ end
+
+ private
+
+ def template(name)
+ ERB.new(File.read(TEMPLATE_PATH.join("#{name}.tt")), trim_mode: '<>').result(binding)
+ end
+end
diff --git a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
new file mode 100644
index 00000000000..70a32ba6f03
--- /dev/null
+++ b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
@@ -0,0 +1,90 @@
+# <%= @project.name %>
+
+<%= @project.description %>
+
+
+## Getting started
+
+To make it easy for you to get started with GitLab, here's a list of recommended next steps.
+
+Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
+
+## Add your files
+
+- [ ] [Create](<%= redirect("https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file") %>) or [upload](<%= redirect("https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file") %>) files
+- [ ] [Add files using the command line](<%= redirect("https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line") %>) or push an existing Git repository with the following command:
+
+```
+cd existing_repo
+git remote add origin <%= @project.http_url_to_repo %>
+git branch -M <%= @project.default_branch_or_main %>
+git push -uf origin <%= @project.default_branch_or_main %>
+```
+
+## Integrate with your tools
+
+- [ ] [Set up project integrations](<%= redirect("https://docs.gitlab.com/ee/user/project/integrations/") %>)
+
+## Collaborate with your team
+
+- [ ] [Invite team members and collaborators](<%= redirect("https://docs.gitlab.com/ee/user/project/members/") %>)
+- [ ] [Create a new merge request](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html") %>)
+- [ ] [Automatically close issues from merge requests](<%= redirect("https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically") %>)
+- [ ] [Automatically merge when pipeline succeeds](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>)
+
+## Test and Deploy
+
+Use the built-in continuous integration in GitLab.
+
+- [ ] [Get started with GitLab CI/CD](<%= redirect("https://docs.gitlab.com/ee/ci/quick_start/index.html") %>)
+- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](<%= redirect("https://docs.gitlab.com/ee/user/application_security/sast/") %>)
+
+***
+
+# Editing this README
+
+When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](<%= redirect("https://www.makeareadme.com/") %>) for this template.
+
+## Suggestions for a good README
+Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
+
+## Name
+Choose a self-explaining name for your project.
+
+## Description
+Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
+
+## Badges
+On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
+
+## Visuals
+Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
+
+## Installation
+Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
+
+## Usage
+Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
+
+## Support
+Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
+
+## Roadmap
+If you have ideas for releases in the future, it is a good idea to list them in the README.
+
+## Contributing
+State if you are open to contributions and what your requirements are for accepting them.
+
+For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
+
+You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
+
+## Authors and acknowledgment
+Show your appreciation to those who have contributed to the project.
+
+## License
+For open source projects, say how it is licensed.
+
+## Project status
+If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+
diff --git a/app/experiments/templates/new_project_readme_content/readme_basic.md.tt b/app/experiments/templates/new_project_readme_content/readme_basic.md.tt
new file mode 100644
index 00000000000..1e68eaf2f05
--- /dev/null
+++ b/app/experiments/templates/new_project_readme_content/readme_basic.md.tt
@@ -0,0 +1,3 @@
+# <%= @project.name %>
+
+<%= @project.description %>
diff --git a/app/finders/bulk_imports/entities_finder.rb b/app/finders/bulk_imports/entities_finder.rb
new file mode 100644
index 00000000000..2947d155668
--- /dev/null
+++ b/app/finders/bulk_imports/entities_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class EntitiesFinder
+ def initialize(user:, bulk_import: nil, status: nil)
+ @user = user
+ @bulk_import = bulk_import
+ @status = status
+ end
+
+ def execute
+ ::BulkImports::Entity
+ .preload(:failures) # rubocop: disable CodeReuse/ActiveRecord
+ .by_user_id(user.id)
+ .then(&method(:filter_by_bulk_import))
+ .then(&method(:filter_by_status))
+ end
+
+ private
+
+ attr_reader :user, :bulk_import, :status
+
+ def filter_by_bulk_import(entities)
+ return entities unless bulk_import
+
+ entities.where(bulk_import_id: bulk_import.id) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def filter_by_status(entities)
+ return entities unless ::BulkImports::Entity.all_human_statuses.include?(status)
+
+ entities.with_status(status)
+ end
+ end
+end
diff --git a/app/finders/bulk_imports/imports_finder.rb b/app/finders/bulk_imports/imports_finder.rb
new file mode 100644
index 00000000000..b554bbfa5e7
--- /dev/null
+++ b/app/finders/bulk_imports/imports_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module BulkImports
+ class ImportsFinder
+ def initialize(user:, status: nil)
+ @user = user
+ @status = status
+ end
+
+ def execute
+ filter_by_status(user.bulk_imports)
+ end
+
+ private
+
+ attr_reader :user, :status
+
+ def filter_by_status(imports)
+ return imports unless BulkImport.all_human_statuses.include?(status)
+
+ imports.with_status(status)
+ end
+ end
+end
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index af7b23278a4..d9fe5c23a7e 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -119,11 +119,12 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def by_username(items)
- if params[:username].present?
- items.joins(:user).where(users: { username: params[:username] })
- else
- items
- end
+ return items unless params[:username].present?
+
+ user_id = User.by_username(params[:username]).pluck_primary_key.first
+ return Ci::Pipeline.none unless user_id
+
+ items.where(user_id: user_id)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index 6c5038128f8..f769da03738 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -47,8 +47,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def pipelines_using_cte
- sha_relation = merge_request.all_commits.select(:sha)
- sha_relation = sha_relation.distinct if Feature.enabled?(:use_distinct_in_shas_cte, default_enabled: :yaml)
+ sha_relation = merge_request.all_commits.select(:sha).distinct
cte = Gitlab::SQL::CTE.new(:shas, sha_relation)
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 7ad51361efd..d34b3202433 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -19,8 +19,9 @@ module Ci
filter_by_runner_type!
filter_by_tag_list!
sort!
+ request_tag_list!
- @runners.with_tags
+ @runners
rescue Gitlab::Access::AccessDeniedError
Ci::Runner.none
@@ -73,6 +74,10 @@ module Ci
@runners = @runners.order_by(sort_key)
end
+ def request_tag_list!
+ @runners = @runners.with_tags if !@params[:preload].present? || @params.dig(:preload, :tag_name)
+ end
+
def filter_by!(scope_name, available_scopes)
scope = @params[scope_name]
diff --git a/app/finders/concerns/merged_at_filter.rb b/app/finders/concerns/merged_at_filter.rb
index e44354f36d1..581bcca3c25 100644
--- a/app/finders/concerns/merged_at_filter.rb
+++ b/app/finders/concerns/merged_at_filter.rb
@@ -10,7 +10,7 @@ module MergedAtFilter
mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present?
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
- join_metrics(items, mr_metrics_scope)
+ items.join_metrics.merge(mr_metrics_scope)
end
def merged_after
@@ -20,22 +20,4 @@ module MergedAtFilter
def merged_before
params[:merged_before]
end
-
- # rubocop: disable CodeReuse/ActiveRecord
- #
- # This join optimizes merged_at queries when the finder is invoked for a project by moving
- # the target_project_id condition from merge_requests table to merge_request_metrics table.
- def join_metrics(items, mr_metrics_scope)
- scope = if project_id = items.where_values_hash["target_project_id"]
- # removing the original merge_requests.target_project_id condition
- items = items.unscope(where: :target_project_id)
- # adding the target_project_id condition to merge_request_metrics
- items.join_metrics(project_id)
- else
- items.join_metrics
- end
-
- scope.merge(mr_metrics_scope)
- end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/container_repositories_finder.rb b/app/finders/container_repositories_finder.rb
index 14e4d6799d8..1f6fa9aa1cc 100644
--- a/app/finders/container_repositories_finder.rb
+++ b/app/finders/container_repositories_finder.rb
@@ -25,8 +25,6 @@ class ContainerRepositoriesFinder
end
def project_repositories
- return unless @subject.container_registry_enabled
-
@subject.container_repositories
end
diff --git a/app/finders/environments/environments_finder.rb b/app/finders/environments/environments_finder.rb
index 190cdb3dec3..46c49f096c6 100644
--- a/app/finders/environments/environments_finder.rb
+++ b/app/finders/environments/environments_finder.rb
@@ -16,6 +16,7 @@ module Environments
environments = project.environments
environments = by_name(environments)
environments = by_search(environments)
+ environments = by_ids(environments)
# Raises InvalidStatesError if params[:states] contains invalid states.
by_states(environments)
@@ -47,6 +48,14 @@ module Environments
end
end
+ def by_ids(environments)
+ if params[:environment_ids].present?
+ environments.for_id(params[:environment_ids])
+ else
+ environments
+ end
+ end
+
def environments_with_states(environments)
# Convert to array of strings
states = Array(params[:states]).map(&:to_s)
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 1ff2ad01b63..ea101cf1dcd 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -83,7 +83,10 @@ class MembersFinder
union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union
sql = distinct_on(union)
- Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
+ # enumerate the columns here since we are enumerating them in the union and want to be immune to
+ # column caching issues when adding/removing columns
+ Member.select(*Member.column_names)
+ .includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
end
def distinct_on(union)
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 19fcd91a5b8..e23fa3f7f68 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -76,6 +76,7 @@ class MergeRequestsFinder < IssuableFinder
def filter_negated_items(items)
items = super(items)
items = by_negated_reviewer(items)
+ items = by_negated_approved_by(items)
by_negated_target_branch(items)
end
@@ -119,6 +120,12 @@ class MergeRequestsFinder < IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_negated_approved_by(items)
+ return items unless not_params[:approved_by_usernames]
+
+ items.not_approved_by_users_with_usernames(not_params[:approved_by_usernames])
+ end
+
def source_project_id
@source_project_id ||= params[:source_project_id].presence
end
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 5d2a54ac979..5fe55e88086 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -18,6 +18,8 @@ class MilestonesFinder
attr_reader :params
+ EXPIRED_LAST_SORTS = %i[expired_last_due_date_asc expired_last_due_date_desc].freeze
+
def initialize(params = {})
@params = params
end
@@ -70,7 +72,16 @@ class MilestonesFinder
end
def order(items)
- sort_by = params[:sort].presence || 'due_date_asc'
- items.sort_by_attribute(sort_by)
+ sort_by = params[:sort].presence || :due_date_asc
+
+ if sort_by_expired_last?(sort_by)
+ items.sort_with_expired_last(sort_by)
+ else
+ items.sort_by_attribute(sort_by)
+ end
+ end
+
+ def sort_by_expired_last?(sort_by)
+ EXPIRED_LAST_SORTS.include?(sort_by)
end
end
diff --git a/app/finders/packages/helm/package_files_finder.rb b/app/finders/packages/helm/package_files_finder.rb
index 74f9eaaca82..ba400b27554 100644
--- a/app/finders/packages/helm/package_files_finder.rb
+++ b/app/finders/packages/helm/package_files_finder.rb
@@ -3,6 +3,9 @@
module Packages
module Helm
class PackageFilesFinder
+ DEFAULT_PACKAGE_FILES_COUNT = 20
+ MAX_PACKAGE_FILES_COUNT = 1000
+
def initialize(project, channel, params = {})
@project = project
@channel = channel
@@ -10,12 +13,18 @@ module Packages
end
def execute
- package_files = Packages::PackageFile.for_helm_with_channel(@project, @channel).preload_helm_file_metadata
+ package_files = Packages::PackageFile.for_helm_with_channel(@project, @channel)
+ .limit_recent(limit)
by_file_name(package_files)
end
private
+ def limit
+ limit_param = @params[:limit] || DEFAULT_PACKAGE_FILES_COUNT
+ [limit_param, MAX_PACKAGE_FILES_COUNT].min
+ end
+
def by_file_name(files)
return files unless @params[:file_name]
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 582075efc4e..dca3d12f3c9 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -55,7 +55,7 @@ class ProjectsFinder < UnionFinder
collection = Project.wrap_with_cte(collection) if use_cte
collection = filter_projects(collection)
- if params[:sort] == 'similarity' && params[:search] && Feature.enabled?(:project_finder_similarity_sort, current_user)
+ if params[:sort] == 'similarity' && params[:search]
collection.sorted_by_similarity_desc(params[:search])
else
sort(collection)
diff --git a/app/finders/security/jobs_finder.rb b/app/finders/security/jobs_finder.rb
index b8649520c20..99bcf97f43c 100644
--- a/app/finders/security/jobs_finder.rb
+++ b/app/finders/security/jobs_finder.rb
@@ -20,7 +20,7 @@ module Security
end
def initialize(pipeline:, job_types: [])
- if self.class == Security::JobsFinder
+ if self.instance_of?(Security::JobsFinder)
raise NotImplementedError, 'This is an abstract class, please instantiate its descendants'
end
@@ -38,7 +38,7 @@ module Security
def execute
return [] if @job_types.empty?
- if Feature.enabled?(:ci_build_metadata_config)
+ if Feature.enabled?(:ci_build_metadata_config, pipeline.project, default_enabled: :yaml)
find_jobs
else
find_jobs_legacy
diff --git a/app/finders/security/security_jobs_finder.rb b/app/finders/security/security_jobs_finder.rb
index 2352e19c7da..008d4e29b13 100644
--- a/app/finders/security/security_jobs_finder.rb
+++ b/app/finders/security/security_jobs_finder.rb
@@ -13,7 +13,7 @@
module Security
class SecurityJobsFinder < JobsFinder
def self.allowed_job_types
- [:sast, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing]
+ [:sast, :dast, :dependency_scanning, :container_scanning, :secret_detection, :coverage_fuzzing, :api_fuzzing, :cluster_image_scanning]
end
end
end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index c6dc85dc07c..4d0a5a5cb13 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -23,7 +23,7 @@ module Mutations
def resolve(args)
project = authorized_find!(args[:project_path])
- return integration_exists if project.prometheus_service
+ return integration_exists if project.prometheus_integration
result = ::Projects::Operations::UpdateService.new(
project,
@@ -32,7 +32,7 @@ module Mutations
**token_attributes
).execute
- response(project.prometheus_service, result)
+ response(project.prometheus_integration, result)
end
private
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
index cb243f49b33..d8678ea4d61 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
@@ -14,7 +14,7 @@ module Mutations
private
def find_object(id:)
- GitlabSchema.object_from_id(id, expected_class: ::PrometheusService)
+ GitlabSchema.object_from_id(id, expected_class: ::Integrations::Prometheus)
end
def response(integration, result)
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
index 428be091436..33a12405583 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
@@ -6,7 +6,7 @@ module Mutations
class ResetToken < PrometheusIntegrationBase
graphql_name 'PrometheusIntegrationResetToken'
- argument :id, Types::GlobalIDType[::PrometheusService],
+ argument :id, Types::GlobalIDType[::Integrations::Prometheus],
required: true,
description: "The ID of the integration to mutate."
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
index 7594766176f..ddab1af908c 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -6,7 +6,7 @@ module Mutations
class Update < PrometheusIntegrationBase
graphql_name 'PrometheusIntegrationUpdate'
- argument :id, Types::GlobalIDType[::PrometheusService],
+ argument :id, Types::GlobalIDType[::Integrations::Prometheus],
required: true,
description: "The ID of the integration to mutate."
diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb
index 4c9752c6343..b73657ea0c8 100644
--- a/app/graphql/mutations/boards/issues/issue_move_list.rb
+++ b/app/graphql/mutations/boards/issues/issue_move_list.rb
@@ -53,8 +53,6 @@ module Mutations
end
def resolve(board:, project_path:, iid:, **args)
- Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/247861')
-
issue = authorized_find!(project_path: project_path, iid: iid)
move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args))
diff --git a/app/graphql/mutations/ci/job_token_scope/add_project.rb b/app/graphql/mutations/ci/job_token_scope/add_project.rb
new file mode 100644
index 00000000000..30f98a537b5
--- /dev/null
+++ b/app/graphql/mutations/ci/job_token_scope/add_project.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module JobTokenScope
+ class AddProject < BaseMutation
+ include FindsProject
+
+ graphql_name 'CiJobTokenScopeAddProject'
+
+ authorize :admin_project
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project that the CI job token scope belongs to.'
+
+ argument :target_project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to be added to the CI job token scope.'
+
+ field :ci_job_token_scope,
+ Types::Ci::JobTokenScopeType,
+ null: true,
+ description: "The CI job token's scope of access."
+
+ def resolve(project_path:, target_project_path:)
+ project = authorized_find!(project_path)
+ target_project = Project.find_by_full_path(target_project_path)
+
+ result = ::Ci::JobTokenScope::AddProjectService
+ .new(project, current_user)
+ .execute(target_project)
+
+ if result.success?
+ {
+ ci_job_token_scope: ::Ci::JobToken::Scope.new(project),
+ errors: []
+ }
+ else
+ {
+ ci_job_token_scope: nil,
+ errors: [result.message]
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/job_token_scope/remove_project.rb b/app/graphql/mutations/ci/job_token_scope/remove_project.rb
new file mode 100644
index 00000000000..71c9083bef8
--- /dev/null
+++ b/app/graphql/mutations/ci/job_token_scope/remove_project.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module JobTokenScope
+ class RemoveProject < BaseMutation
+ include FindsProject
+
+ graphql_name 'CiJobTokenScopeRemoveProject'
+
+ authorize :admin_project
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project that the CI job token scope belongs to.'
+
+ argument :target_project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'The project to be removed from the CI job token scope.'
+
+ field :ci_job_token_scope,
+ Types::Ci::JobTokenScopeType,
+ null: true,
+ description: "The CI job token's scope of access."
+
+ def resolve(project_path:, target_project_path:)
+ project = authorized_find!(project_path)
+ target_project = Project.find_by_full_path(target_project_path)
+
+ result = ::Ci::JobTokenScope::RemoveProjectService
+ .new(project, current_user)
+ .execute(target_project)
+
+ if result.success?
+ {
+ ci_job_token_scope: ::Ci::JobToken::Scope.new(project),
+ errors: []
+ }
+ else
+ {
+ ci_job_token_scope: nil,
+ errors: [result.message]
+ }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/runner/update.rb b/app/graphql/mutations/ci/runner/update.rb
index 5b61b2ffc0d..4cdfa1fb1bd 100644
--- a/app/graphql/mutations/ci/runner/update.rb
+++ b/app/graphql/mutations/ci/runner/update.rb
@@ -66,3 +66,5 @@ module Mutations
end
end
end
+
+Mutations::Ci::Runner::Update.prepend_mod_with('Mutations::Ci::Runner::Update')
diff --git a/app/graphql/mutations/concerns/mutations/package_eventable.rb b/app/graphql/mutations/concerns/mutations/package_eventable.rb
index 86fd7b9a88a..134e3659125 100644
--- a/app/graphql/mutations/concerns/mutations/package_eventable.rb
+++ b/app/graphql/mutations/concerns/mutations/package_eventable.rb
@@ -8,7 +8,7 @@ module Mutations
def track_event(event, scope)
::Packages::CreateEventService.new(nil, current_user, event_name: event, scope: scope).execute
- ::Gitlab::Tracking.event(event.to_s, scope.to_s)
+ ::Gitlab::Tracking.event(event.to_s, scope.to_s, user: current_user)
end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/spam_protection.rb b/app/graphql/mutations/concerns/mutations/spam_protection.rb
index d765da23a4b..341067710b2 100644
--- a/app/graphql/mutations/concerns/mutations/spam_protection.rb
+++ b/app/graphql/mutations/concerns/mutations/spam_protection.rb
@@ -16,25 +16,6 @@ module Mutations
private
- # additional_spam_params -> hash
- #
- # Used from a spammable mutation's #resolve method to generate
- # the required additional spam/CAPTCHA params which must be merged into the params
- # passed to the constructor of a service, where they can then be used in the service
- # to perform spam checking via SpamActionService.
- #
- # Also accesses the #context of the mutation's Resolver superclass to obtain the request.
- #
- # Example:
- #
- # existing_args.merge!(additional_spam_params)
- def additional_spam_params
- {
- api: true,
- request: context[:request]
- }
- end
-
def spam_action_response(object)
fields = spam_action_response_fields(object)
diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
index 3a57e2434a5..7c4a851f8aa 100644
--- a/app/graphql/mutations/issues/create.rb
+++ b/app/graphql/mutations/issues/create.rb
@@ -73,7 +73,8 @@ module Mutations
project = authorized_find!(project_path)
params = build_create_issue_params(attributes.merge(author_id: current_user.id))
- issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params).execute
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+ issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: spam_params).execute
if issue.spam?
issue.errors.add(:base, 'Spam detected.')
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
index 8e88b31d9ed..cfee2420ee0 100644
--- a/app/graphql/mutations/issues/set_confidential.rb
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -3,6 +3,8 @@
module Mutations
module Issues
class SetConfidential < Base
+ include Mutations::SpamProtection
+
graphql_name 'IssueSetConfidential'
argument :confidential,
@@ -13,9 +15,13 @@ module Mutations
def resolve(project_path:, iid:, confidential:)
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
+ # Changing confidentiality affects spam checking rules, therefore we need to provide
+ # spam_params so a check can be performed.
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
- ::Issues::UpdateService.new(project: project, current_user: current_user, params: { confidential: confidential })
+ ::Issues::UpdateService.new(project: project, current_user: current_user, params: { confidential: confidential }, spam_params: spam_params)
.execute(issue)
+ check_spam_action_response!(issue)
{
issue: issue,
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index eb16b7b38d0..1ceed868a6c 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -31,7 +31,8 @@ module Mutations
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
- ::Issues::UpdateService.new(project: project, current_user: current_user, params: args).execute(issue)
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+ ::Issues::UpdateService.new(project: project, current_user: current_user, params: args, spam_params: spam_params).execute(issue)
{
issue: issue,
diff --git a/app/graphql/mutations/packages/destroy.rb b/app/graphql/mutations/packages/destroy.rb
new file mode 100644
index 00000000000..979a54da6bd
--- /dev/null
+++ b/app/graphql/mutations/packages/destroy.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Packages
+ class Destroy < ::Mutations::BaseMutation
+ graphql_name 'DestroyPackage'
+
+ authorize :destroy_package
+
+ argument :id,
+ ::Types::GlobalIDType[::Packages::Package],
+ required: true,
+ description: 'ID of the Package.'
+
+ def resolve(id:)
+ package = authorized_find!(id: id)
+
+ result = ::Packages::DestroyPackageService.new(container: package, current_user: current_user).execute
+
+ errors = result.error? ? Array.wrap(result[:message]) : []
+
+ {
+ errors: errors
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Packages::Package].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/release_asset_links/create.rb b/app/graphql/mutations/release_asset_links/create.rb
index 02704efb47c..ff9d98d2c0f 100644
--- a/app/graphql/mutations/release_asset_links/create.rb
+++ b/app/graphql/mutations/release_asset_links/create.rb
@@ -33,6 +33,10 @@ module Mutations
return { link: nil, errors: [message] }
end
+ unless Ability.allowed?(current_user, :update_release, release)
+ raise_resource_not_available_error!
+ end
+
new_link = release.links.create(link_attrs)
unless new_link.persisted?
diff --git a/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb
new file mode 100644
index 00000000000..090a9a4e0ef
--- /dev/null
+++ b/app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Security
+ module CiConfiguration
+ class BaseSecurityAnalyzer < BaseMutation
+ include FindsProject
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: 'Full path of the project.'
+
+ field :success_path, GraphQL::STRING_TYPE, null: true,
+ description: 'Redirect path to use when the response is successful.'
+
+ field :branch, GraphQL::STRING_TYPE, null: true,
+ description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
+
+ authorize :push_code
+
+ def resolve(project_path:, **args)
+ project = authorized_find!(project_path)
+
+ result = configure_analyzer(project, **args)
+ prepare_response(result)
+ end
+
+ private
+
+ def configure_analyzer(project, **args)
+ raise NotImplementedError
+ end
+
+ def prepare_response(result)
+ {
+ branch: result.payload[:branch],
+ success_path: result.payload[:success_path],
+ errors: result.errors
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/security/ci_configuration/configure_sast.rb b/app/graphql/mutations/security/ci_configuration/configure_sast.rb
index 237aff1f052..7ce0bf83a4b 100644
--- a/app/graphql/mutations/security/ci_configuration/configure_sast.rb
+++ b/app/graphql/mutations/security/ci_configuration/configure_sast.rb
@@ -3,9 +3,7 @@
module Mutations
module Security
module CiConfiguration
- class ConfigureSast < BaseMutation
- include FindsProject
-
+ class ConfigureSast < BaseSecurityAnalyzer
graphql_name 'ConfigureSast'
description <<~DESC
Configure SAST for a project by enabling SAST in a new or modified
@@ -13,37 +11,12 @@ module Mutations
create a Merge Request are a part of the response.
DESC
- argument :project_path, GraphQL::ID_TYPE,
- required: true,
- description: 'Full path of the project.'
-
argument :configuration, ::Types::CiConfiguration::Sast::InputType,
required: true,
description: 'SAST CI configuration for the project.'
- field :success_path, GraphQL::STRING_TYPE, null: true,
- description: 'Redirect path to use when the response is successful.'
-
- field :branch, GraphQL::STRING_TYPE, null: true,
- description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
-
- authorize :push_code
-
- def resolve(project_path:, configuration:)
- project = authorized_find!(project_path)
-
- result = ::Security::CiConfiguration::SastCreateService.new(project, current_user, configuration).execute
- prepare_response(result)
- end
-
- private
-
- def prepare_response(result)
- {
- branch: result.payload[:branch],
- success_path: result.payload[:success_path],
- errors: result.errors
- }
+ def configure_analyzer(project, **args)
+ ::Security::CiConfiguration::SastCreateService.new(project, current_user, args[:configuration]).execute
end
end
end
diff --git a/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb b/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb
index 32ad670edaa..54322babb26 100644
--- a/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb
+++ b/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb
@@ -3,9 +3,7 @@
module Mutations
module Security
module CiConfiguration
- class ConfigureSecretDetection < BaseMutation
- include FindsProject
-
+ class ConfigureSecretDetection < BaseSecurityAnalyzer
graphql_name 'ConfigureSecretDetection'
description <<~DESC
Configure Secret Detection for a project by enabling Secret Detection
@@ -14,33 +12,8 @@ module Mutations
response.
DESC
- argument :project_path, GraphQL::ID_TYPE,
- required: true,
- description: 'Full path of the project.'
-
- field :success_path, GraphQL::STRING_TYPE, null: true,
- description: 'Redirect path to use when the response is successful.'
-
- field :branch, GraphQL::STRING_TYPE, null: true,
- description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
-
- authorize :push_code
-
- def resolve(project_path:)
- project = authorized_find!(project_path)
-
- result = ::Security::CiConfiguration::SecretDetectionCreateService.new(project, current_user).execute
- prepare_response(result)
- end
-
- private
-
- def prepare_response(result)
- {
- branch: result.payload[:branch],
- success_path: result.payload[:success_path],
- errors: result.errors
- }
+ def configure_analyzer(project, **_args)
+ ::Security::CiConfiguration::SecretDetectionCreateService.new(project, current_user).execute
end
end
end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index d1ad0697acd..765163e73a1 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -49,7 +49,9 @@ module Mutations
process_args_for_params!(args)
- service_response = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args).execute
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+ service = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args, spam_params: spam_params)
+ service_response = service.execute
# Only when the user is not an api user and the operation was successful
if !api_user? && service_response.success?
@@ -81,12 +83,6 @@ module Mutations
# it's the expected key param
args[:files] = args.delete(:uploaded_files)
- if Feature.enabled?(:snippet_spam)
- args.merge!(additional_spam_params)
- else
- args[:disable_spam_action_service] = true
- end
-
# Return nil to make it explicit that this method is mutating the args parameter, and that
# the return value is not relevant and is not to be used.
nil
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 2e1382e1cb1..792c631e5ca 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -34,7 +34,9 @@ module Mutations
process_args_for_params!(args)
- service_response = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args).execute(snippet)
+ spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
+ service = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args, spam_params: spam_params)
+ service_response = service.execute(snippet)
# TODO: DRY this up - From here down, this is all duplicated with Mutations::Snippets::Create#resolve, except for
# `snippet.reset`, which is required in order to return the object in its non-dirty, unmodified, database state
@@ -62,12 +64,6 @@ module Mutations
def process_args_for_params!(args)
convert_blob_actions_to_snippet_actions!(args)
- if Feature.enabled?(:snippet_spam)
- args.merge!(additional_spam_params)
- else
- args[:disable_spam_action_service] = true
- end
-
# Return nil to make it explicit that this method is mutating the args parameter, and that
# the return value is not relevant and is not to be used.
nil
diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
index 4683ef9dfdb..df0b590acac 100644
--- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql
+++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql
@@ -29,6 +29,7 @@ query getProjectContainerRepositories(
canDelete
createdAt
expirationPolicyStartedAt
+ expirationPolicyCleanupStatus
__typename
}
pageInfo {
@@ -61,6 +62,7 @@ query getProjectContainerRepositories(
canDelete
createdAt
expirationPolicyStartedAt
+ expirationPolicyCleanupStatus
__typename
}
pageInfo {
diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql
index 5ee27052f95..b0e55811b7d 100644
--- a/app/graphql/queries/epic/epic_children.query.graphql
+++ b/app/graphql/queries/epic/epic_children.query.graphql
@@ -42,6 +42,7 @@ fragment EpicNode on Epic {
relationPath
createdAt
closedAt
+ confidential
hasChildren
hasIssues
group {
diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
index 873ecc81466..4e4caa1e27c 100644
--- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
+++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql
@@ -29,6 +29,9 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
iid
complete
usesNeeds
+ userPermissions {
+ updatePipeline
+ }
downstream {
__typename
nodes {
diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql
new file mode 100644
index 00000000000..ab8cbcb8aa3
--- /dev/null
+++ b/app/graphql/queries/releases/all_releases.query.graphql
@@ -0,0 +1,105 @@
+# This query is identical to
+# `app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql`.
+# These two queries should be kept in sync.
+query allReleases(
+ $fullPath: ID!
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+ $sort: ReleaseSort
+) {
+ project(fullPath: $fullPath) {
+ __typename
+ releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
+ __typename
+ nodes {
+ __typename
+ name
+ tagName
+ tagPath
+ descriptionHtml
+ releasedAt
+ createdAt
+ upcomingRelease
+ assets {
+ __typename
+ count
+ sources {
+ __typename
+ nodes {
+ __typename
+ format
+ url
+ }
+ }
+ links {
+ __typename
+ nodes {
+ __typename
+ id
+ name
+ url
+ directAssetUrl
+ linkType
+ external
+ }
+ }
+ }
+ evidences {
+ __typename
+ nodes {
+ __typename
+ filepath
+ collectedAt
+ sha
+ }
+ }
+ links {
+ __typename
+ editUrl
+ selfUrl
+ openedIssuesUrl
+ closedIssuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ }
+ commit {
+ __typename
+ sha
+ webUrl
+ title
+ }
+ author {
+ __typename
+ webUrl
+ avatarUrl
+ username
+ }
+ milestones {
+ __typename
+ nodes {
+ __typename
+ id
+ title
+ description
+ webPath
+ stats {
+ __typename
+ totalIssuesCount
+ closedIssuesCount
+ }
+ }
+ }
+ }
+ pageInfo {
+ __typename
+ startCursor
+ hasPreviousPage
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+}
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
index 008641ed88a..62744e719da 100644
--- a/app/graphql/resolvers/alert_management/alert_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -12,7 +12,7 @@ module Resolvers
argument :statuses, [Types::AlertManagement::StatusEnum],
as: :status,
required: false,
- description: 'Alerts with the specified statues. For example, [TRIGGERED].'
+ description: 'Alerts with the specified statues. For example, `[TRIGGERED]`.'
argument :sort, Types::AlertManagement::AlertSortEnum,
description: 'Sort alerts by this criteria.',
diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb
index cb7e73c2d1a..a97650e95d9 100644
--- a/app/graphql/resolvers/alert_management/integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb
@@ -34,7 +34,7 @@ module Resolvers
def prometheus_integrations
return [] unless prometheus_integrations_allowed?
- Array(project.prometheus_service)
+ Array(project.prometheus_integration)
end
def http_integrations
@@ -54,7 +54,7 @@ module Resolvers
def expected_integration_types
[].tap do |types|
types << ::AlertManagement::HttpIntegration if http_integrations_allowed?
- types << ::PrometheusService if prometheus_integrations_allowed?
+ types << ::Integrations::Prometheus if prometheus_integrations_allowed?
end
end
end
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index dac93b91469..25fb35ec74b 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
filter_params = item_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
- offset_pagination(service.execute)
+ service.execute
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
index 252c9d3acf0..f2e33251b9c 100644
--- a/app/graphql/resolvers/ci/config_resolver.rb
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -18,6 +18,10 @@ module Resolvers
required: true,
description: 'The project of the CI config.'
+ argument :sha, GraphQL::STRING_TYPE,
+ required: false,
+ description: "Sha for the pipeline."
+
argument :content, GraphQL::STRING_TYPE,
required: true,
description: "Contents of `.gitlab-ci.yml`."
@@ -26,11 +30,11 @@ module Resolvers
required: false,
description: 'Run pipeline creation simulation, or only do static check.'
- def resolve(project_path:, content:, dry_run: false)
+ def resolve(project_path:, content:, sha: nil, dry_run: false)
project = authorized_find!(project_path: project_path)
result = ::Gitlab::Ci::Lint
- .new(project: project, current_user: context[:current_user])
+ .new(project: project, current_user: context[:current_user], sha: sha)
.validate(content, dry_run: dry_run)
response(result).merge(merged_yaml: result.merged_yaml)
diff --git a/app/graphql/resolvers/ci/job_token_scope_resolver.rb b/app/graphql/resolvers/ci/job_token_scope_resolver.rb
new file mode 100644
index 00000000000..ca76a7b94fc
--- /dev/null
+++ b/app/graphql/resolvers/ci/job_token_scope_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class JobTokenScopeResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :admin_project
+ description 'Container for resources that can be accessed by a CI job token from the current project. Null if job token scope setting is disabled.'
+ type ::Types::Ci::JobTokenScopeType, null: true
+
+ def resolve
+ authorize!(object)
+
+ return unless object.ci_job_token_scope_enabled?
+
+ ::Ci::JobToken::Scope.new(object)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 3ad1e2780dd..5074a248e18 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module Ci
class RunnersResolver < BaseResolver
+ include LooksAhead
+
type Types::Ci::RunnerType.connection_type, null: true
argument :status, ::Types::Ci::RunnerStatusEnum,
@@ -25,10 +27,11 @@ module Resolvers
required: false,
description: 'Sort order of results.'
- def resolve(**args)
- ::Ci::RunnersFinder
- .new(current_user: current_user, params: runners_finder_params(args))
- .execute
+ def resolve_with_lookahead(**args)
+ apply_lookahead(
+ ::Ci::RunnersFinder
+ .new(current_user: current_user, params: runners_finder_params(args))
+ .execute)
end
private
@@ -39,7 +42,10 @@ module Resolvers
type_type: params[:type],
tag_name: params[:tag_list],
search: params[:search],
- sort: params[:sort]&.to_s
+ sort: params[:sort]&.to_s,
+ preload: {
+ tag_name: node_selection&.selects?(:tag_list)
+ }
}.compact
end
end
diff --git a/app/graphql/resolvers/ci/template_resolver.rb b/app/graphql/resolvers/ci/template_resolver.rb
index dd910116544..7f5a1a486d7 100644
--- a/app/graphql/resolvers/ci/template_resolver.rb
+++ b/app/graphql/resolvers/ci/template_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
type Types::Ci::TemplateType, null: true
argument :name, GraphQL::STRING_TYPE, required: true,
- description: 'Name of the CI/CD template to search for.'
+ description: 'Name of the CI/CD template to search for. Template must be formatted as `Name.gitlab-ci.yml`.'
alias_method :project, :object
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index aa08d62c6a5..c24f4dedc0e 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -11,7 +11,7 @@ module IssueResolverArguments
description: 'IID of the issue. For example, "1".'
argument :iids, [GraphQL::STRING_TYPE],
required: false,
- description: 'List of IIDs of issues. For example, ["1", "2"].'
+ description: 'List of IIDs of issues. For example, `["1", "2"]`.'
argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Labels applied to this issue.'
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 93e679b2d0c..2017eb7decd 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -15,6 +15,7 @@ module Resolvers
type Types::IssueType.connection_type, null: true
NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
+ popularity_asc popularity_desc
label_priority_asc label_priority_desc
milestone_due_asc milestone_due_desc].freeze
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 1241b41501d..4fa4c939a23 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -25,14 +25,27 @@ module Resolvers
required: false,
description: 'A date that the milestone contains.'
+ argument :sort, Types::MilestoneSortEnum,
+ description: 'Sort milestones by this criteria.',
+ required: false,
+ default_value: :due_date_asc
+
type Types::MilestoneType.connection_type, null: true
+ NON_STABLE_CURSOR_SORTS = %i[expired_last_due_date_asc expired_last_due_date_desc].freeze
+
def resolve(**args)
validate_timeframe_params!(args)
authorize!
- MilestonesFinder.new(milestones_finder_params(args)).execute
+ milestones = MilestonesFinder.new(milestones_finder_params(args)).execute
+
+ if non_stable_cursor_sort?(args[:sort])
+ offset_pagination(milestones)
+ else
+ milestones
+ end
end
private
@@ -43,6 +56,7 @@ module Resolvers
state: args[:state] || 'all',
title: args[:title],
search_title: args[:search_title],
+ sort: args[:sort],
containing_date: args[:containing_date]
}.merge!(transform_timeframe_parameters(args)).merge!(parent_id_parameters(args))
end
@@ -64,5 +78,9 @@ module Resolvers
def parse_gids(gids)
gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: Milestone).model_id }
end
+
+ def non_stable_cursor_sort?(sort)
+ NON_STABLE_CURSOR_SORTS.include?(sort)
+ end
end
end
diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb
index de85e8c42e6..864acb6d759 100644
--- a/app/graphql/resolvers/projects/jira_projects_resolver.rb
+++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb
@@ -34,16 +34,16 @@ module Resolvers
private
- alias_method :jira_service, :object
+ alias_method :jira_integration, :object
def project
- jira_service&.project
+ jira_integration&.project
end
def jira_projects(name:)
args = { query: name }.compact
- Jira::Requests::Projects::ListService.new(project.jira_service, args).execute
+ Jira::Requests::Projects::ListService.new(project.jira_integration, args).execute
end
end
end
diff --git a/app/graphql/types/alert_management/alert_sort_enum.rb b/app/graphql/types/alert_management/alert_sort_enum.rb
index 11a5cb95722..b4d09734e30 100644
--- a/app/graphql/types/alert_management/alert_sort_enum.rb
+++ b/app/graphql/types/alert_management/alert_sort_enum.rb
@@ -18,8 +18,8 @@ module Types
value 'EVENT_COUNT_DESC', 'Events count by descending order.', value: :event_count_desc
value 'SEVERITY_ASC', 'Severity from less critical to more critical.', value: :severity_asc
value 'SEVERITY_DESC', 'Severity from more critical to less critical.', value: :severity_desc
- value 'STATUS_ASC', 'Status by order: Ignored > Resolved > Acknowledged > Triggered.', value: :status_asc
- value 'STATUS_DESC', 'Status by order: Triggered > Acknowledged > Resolved > Ignored.', value: :status_desc
+ value 'STATUS_ASC', 'Status by order: `Ignored > Resolved > Acknowledged > Triggered`.', value: :status_asc
+ value 'STATUS_DESC', 'Status by order: `Triggered > Acknowledged > Resolved > Ignored`.', value: :status_desc
end
end
end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 5a2a5c68c8d..0ff0775ca86 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -8,7 +8,7 @@ module Types
present_using ::AlertManagement::AlertPresenter
- implements(Types::Notes::NoteableType)
+ implements(Types::Notes::NoteableInterface)
authorize :read_alert_management_alert
diff --git a/app/graphql/types/alert_management/integration_type.rb b/app/graphql/types/alert_management/integration_type.rb
index d26d7348765..6cbc17cdbfb 100644
--- a/app/graphql/types/alert_management/integration_type.rb
+++ b/app/graphql/types/alert_management/integration_type.rb
@@ -43,7 +43,7 @@ module Types
definition_methods do
def resolve_type(object, context)
- if object.is_a?(::PrometheusService)
+ if object.is_a?(::Integrations::Prometheus)
Types::AlertManagement::PrometheusIntegrationType
else
Types::AlertManagement::HttpIntegrationType
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
index 79f265f2f1e..27e4832d8f6 100644
--- a/app/graphql/types/alert_management/prometheus_integration_type.rb
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -12,10 +12,10 @@ module Types
authorize :admin_project
- alias_method :prometheus_service, :object
+ alias_method :prometheus_integration, :object
def name
- prometheus_service.title
+ prometheus_integration.title
end
def type
@@ -23,15 +23,15 @@ module Types
end
def token
- prometheus_service.project&.alerting_setting&.token
+ prometheus_integration.project&.alerting_setting&.token
end
def url
- prometheus_service.project && notify_project_prometheus_alerts_url(prometheus_service.project, format: :json)
+ prometheus_integration.project && notify_project_prometheus_alerts_url(prometheus_integration.project, format: :json)
end
def active
- prometheus_service.manual_configuration?
+ prometheus_integration.manual_configuration?
end
end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 47caf83eb1c..75fdb41ceb6 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -65,7 +65,7 @@ module Types
end
def visible?(context)
- return false if feature_flag.present? && !Feature.enabled?(feature_flag)
+ return false if feature_flag.present? && !Feature.enabled?(feature_flag, default_enabled: :yaml)
super
end
@@ -95,7 +95,15 @@ module Types
end
def feature_documentation_message(key, description)
- "#{description} Available only when feature flag `#{key}` is enabled."
+ message_parts = ["#{description} Available only when feature flag `#{key}` is enabled."]
+
+ message_parts << if Feature::Definition.has_definition?(key) && Feature::Definition.default_enabled?(key)
+ "This flag is enabled by default."
+ else
+ "This flag is disabled by default, because the feature is experimental and is subject to change without notice."
+ end
+
+ message_parts.join(' ')
end
def check_feature_flag(args)
diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb
index 633221e61d1..30f4efcd403 100644
--- a/app/graphql/types/boards/board_issue_input_base_type.rb
+++ b/app/graphql/types/boards/board_issue_input_base_type.rb
@@ -6,7 +6,7 @@ module Types
class BoardIssueInputBaseType < BoardIssuableInputBaseType
argument :iids, [GraphQL::STRING_TYPE],
required: false,
- description: 'List of IIDs of issues. For example ["1", "2"].'
+ description: 'List of IIDs of issues. For example `["1", "2"]`.'
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
diff --git a/app/graphql/types/ci/build_need_type.rb b/app/graphql/types/ci/build_need_type.rb
index 3bd81f8fa8f..19ff758ad1d 100644
--- a/app/graphql/types/ci/build_need_type.rb
+++ b/app/graphql/types/ci/build_need_type.rb
@@ -7,6 +7,8 @@ module Types
class BuildNeedType < BaseObject
graphql_name 'CiBuildNeed'
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the job we need to complete.'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the job we need to complete.'
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 0b643a6b676..6310a62a103 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -6,6 +6,9 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
+ field :id, GraphQL::STRING_TYPE, null: false,
+ description: 'ID for a detailed status.',
+ extras: [:parent]
field :group, GraphQL::STRING_TYPE, null: true,
description: 'Group of the status.'
field :icon, GraphQL::STRING_TYPE, null: true,
@@ -29,6 +32,10 @@ module Types
calls_gitaly: true,
description: 'Action information for the status. This includes method, button title, icon, path, and title.'
+ def id(parent:)
+ "#{object.id}-#{parent.object.object.id}"
+ end
+
def action
if object.has_action?
{
diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb
index d6d4252e8d7..3da183cb842 100644
--- a/app/graphql/types/ci/group_type.rb
+++ b/app/graphql/types/ci/group_type.rb
@@ -6,12 +6,14 @@ module Types
class GroupType < BaseObject
graphql_name 'CiGroup'
+ field :id, GraphQL::STRING_TYPE, null: false,
+ description: 'ID for a group.'
field :name, GraphQL::STRING_TYPE, null: true,
- description: 'Name of the job group.'
+ description: 'Name of the job group.'
field :size, GraphQL::INT_TYPE, null: true,
- description: 'Size of the group.'
+ description: 'Size of the group.'
field :jobs, Ci::JobType.connection_type, null: true,
- description: 'Jobs in group.'
+ description: 'Jobs in group.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the group.'
diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb
new file mode 100644
index 00000000000..9f48298e1d3
--- /dev/null
+++ b/app/graphql/types/ci/job_token_scope_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # Authorization is in the resolver based on the parent project
+ module Ci
+ class JobTokenScopeType < BaseObject
+ graphql_name 'CiJobTokenScopeType'
+
+ field :projects, Types::ProjectType.connection_type, null: false,
+ description: 'Allow list of projects that can be accessed by CI Job tokens created by this project.',
+ method: :all_projects
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 5ed4d823aee..360ea3ba7a9 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -56,7 +56,7 @@ module Types
field :short_sha, type: GraphQL::STRING_TYPE, null: false,
description: 'Short SHA1 ID of the commit.'
field :scheduling_type, GraphQL::STRING_TYPE, null: true,
- description: 'Type of pipeline scheduling. Value is `dag` if the pipeline uses the `needs` keyword, and `stage` otherwise.'
+ description: 'Type of job scheduling. Value is `dag` if the job uses the `needs` keyword, and `stage` otherwise.'
field :commit_path, GraphQL::STRING_TYPE, null: true,
description: 'Path to the commit that triggered the job.'
field :ref_name, GraphQL::STRING_TYPE, null: true,
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 2eeddaca6ba..f4a6c18f73e 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -150,6 +150,9 @@ module Types
description: 'A specific test suite in a pipeline test report.',
resolver: Resolvers::Ci::TestSuiteResolver
+ field :ref, GraphQL::STRING_TYPE, null: true,
+ description: 'Reference to the branch from which the pipeline was triggered.'
+
def detailed_status
object.detailed_status(current_user)
end
diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb
index 837d91ef765..9c5041b0860 100644
--- a/app/graphql/types/ci/runner_type.rb
+++ b/app/graphql/types/ci/runner_type.rb
@@ -6,6 +6,10 @@ module Types
graphql_name 'CiRunner'
authorize :read_runner
+ JOB_COUNT_LIMIT = 1000
+
+ alias_method :runner, :object
+
field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
description: 'ID of the runner.'
field :description, GraphQL::STRING_TYPE, null: true,
@@ -21,22 +25,48 @@ module Types
description: 'Indicates the runner is allowed to receive jobs.'
field :status, ::Types::Ci::RunnerStatusEnum, null: false,
description: 'Status of the runner.'
- field :version, GraphQL::STRING_TYPE, null: false,
+ field :version, GraphQL::STRING_TYPE, null: true,
description: 'Version of the runner.'
field :short_sha, GraphQL::STRING_TYPE, null: true,
description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
- field :revision, GraphQL::STRING_TYPE, null: false,
+ field :revision, GraphQL::STRING_TYPE, null: true,
description: 'Revision of the runner.'
field :locked, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates the runner is locked.'
field :run_untagged, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the runner is able to run untagged jobs.'
- field :ip_address, GraphQL::STRING_TYPE, null: false,
+ field :ip_address, GraphQL::STRING_TYPE, null: true,
description: 'IP address of the runner.'
field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
description: 'Type of the runner.'
field :tag_list, [GraphQL::STRING_TYPE], null: true,
description: 'Tags associated with the runner.'
+ field :project_count, GraphQL::INT_TYPE, null: true,
+ description: 'Number of projects that the runner is associated with.'
+ field :job_count, GraphQL::INT_TYPE, null: true,
+ description: "Number of jobs processed by the runner (limited to #{JOB_COUNT_LIMIT}, plus one to indicate that more items exist)."
+
+ def job_count
+ # We limit to 1 above the JOB_COUNT_LIMIT to indicate that more items exist after JOB_COUNT_LIMIT
+ runner.builds.limit(JOB_COUNT_LIMIT + 1).count
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def project_count
+ BatchLoader::GraphQL.for(runner.id).batch(key: :runner_project_count) do |ids, loader, args|
+ counts = ::Ci::Runner.project_type
+ .select(:id, 'COUNT(ci_runner_projects.id) as count')
+ .left_outer_joins(:runner_projects)
+ .where(id: ids)
+ .group(:id)
+ .index_by(&:id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count)
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index 1be9e3192a9..ce3edb6c54f 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -6,22 +6,21 @@ module Types
graphql_name 'CiStage'
authorize :read_commit_status
- field :name,
- type: GraphQL::STRING_TYPE,
- null: true,
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the stage.'
+ field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the stage.'
- field :groups,
- type: Ci::GroupType.connection_type,
- null: true,
+ field :groups, type: Ci::GroupType.connection_type, null: true,
extras: [:lookahead],
description: 'Group of jobs for the stage.'
- field :detailed_status, Types::Ci::DetailedStatusType,
- null: true,
+ field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the stage.'
- field :jobs, Ci::JobType.connection_type,
- null: true,
+ field :jobs, Ci::JobType.connection_type, null: true,
description: 'Jobs for the stage.',
method: 'latest_statuses'
+ field :status, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Status of the pipeline stage.'
def detailed_status
object.detailed_status(current_user)
@@ -54,6 +53,7 @@ module Types
# rubocop: disable CodeReuse/ActiveRecord
def jobs_for_pipeline(pipeline, stage_ids, include_needs)
results = pipeline.latest_statuses.where(stage_id: stage_ids)
+ results = results.preload(:project)
results = results.preload(:needs) if include_needs
results.group_by(&:stage_id)
diff --git a/app/graphql/types/ci/status_action_type.rb b/app/graphql/types/ci/status_action_type.rb
index 9f7299c0270..a06b09735b3 100644
--- a/app/graphql/types/ci/status_action_type.rb
+++ b/app/graphql/types/ci/status_action_type.rb
@@ -5,6 +5,9 @@ module Types
class StatusActionType < BaseObject
graphql_name 'StatusAction'
+ field :id, GraphQL::STRING_TYPE, null: false,
+ description: 'ID for a status action.',
+ extras: [:parent]
field :button_title, GraphQL::STRING_TYPE, null: true,
description: 'Title for the button, for example: Retry this job.'
field :icon, GraphQL::STRING_TYPE, null: true,
@@ -17,6 +20,10 @@ module Types
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title for the action, for example: Retry.'
+ def id(parent:)
+ "#{parent.parent.object.object.class.name}-#{parent.object.object.id}"
+ end
+
def action_method
object[:method]
end
diff --git a/app/graphql/types/deployment_tier_enum.rb b/app/graphql/types/deployment_tier_enum.rb
new file mode 100644
index 00000000000..7079b98c512
--- /dev/null
+++ b/app/graphql/types/deployment_tier_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class DeploymentTierEnum < BaseEnum
+ graphql_name 'DeploymentTier'
+ description 'All environment deployment tiers.'
+
+ value 'PRODUCTION', description: 'Production.', value: :production
+ value 'STAGING', description: 'Staging.', value: :staging
+ value 'TESTING', description: 'Testing.', value: :testing
+ value 'DEVELOPMENT', description: 'Development.', value: :development
+ value 'OTHER', description: 'Other.', value: :other
+ end
+end
diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb
index 44e87905f92..2f40bf5ebfd 100644
--- a/app/graphql/types/design_management/design_type.rb
+++ b/app/graphql/types/design_management/design_type.rb
@@ -10,7 +10,7 @@ module Types
alias_method :design, :object
- implements(Types::Notes::NoteableType)
+ implements(Types::Notes::NoteableInterface)
implements(Types::DesignManagement::DesignFields)
implements(Types::CurrentUserTodos)
diff --git a/app/graphql/types/issuable_searchable_field_enum.rb b/app/graphql/types/issuable_searchable_field_enum.rb
new file mode 100644
index 00000000000..88a49504f28
--- /dev/null
+++ b/app/graphql/types/issuable_searchable_field_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class IssuableSearchableFieldEnum < BaseEnum
+ graphql_name 'IssuableSearchableField'
+ description 'Fields to perform the search in'
+
+ Issuable::SEARCHABLE_FIELDS.each do |field|
+ value field.upcase, value: field, description: "Search in #{field} field."
+ end
+ end
+end
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index e730a51b60e..a2390ff01fe 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -10,6 +10,8 @@ module Types
value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order.', value: :relative_position_asc
value 'SEVERITY_ASC', 'Severity from less critical to more critical.', value: :severity_asc
value 'SEVERITY_DESC', 'Severity from more critical to less critical.', value: :severity_desc
+ value 'POPULARITY_ASC', 'Number of upvotes (awarded "thumbs up" emoji) by ascending order.', value: :popularity_asc
+ value 'POPULARITY_DESC', 'Number of upvotes (awarded "thumbs up" emoji) by descending order.', value: :popularity_desc
end
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 0ccd1e2cebd..6ff38273c03 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -6,7 +6,7 @@ module Types
connection_type_class(Types::IssueConnectionType)
- implements(Types::Notes::NoteableType)
+ implements(Types::Notes::NoteableInterface)
implements(Types::CurrentUserTodos)
authorize :read_issue
@@ -127,6 +127,9 @@ module Types
field :timelogs, Types::TimelogType.connection_type, null: false,
description: 'Timelogs on the issue.'
+ field :project_id, GraphQL::INT_TYPE, null: false, method: :project_id,
+ description: 'ID of the issue project.'
+
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb
index 8a2e75ed9ba..88faf7e7074 100644
--- a/app/graphql/types/issues/negated_issue_filter_input_type.rb
+++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb
@@ -7,7 +7,7 @@ module Types
argument :iids, [GraphQL::STRING_TYPE],
required: false,
- description: 'List of IIDs of issues to exclude. For example, [1, 2].'
+ description: 'List of IIDs of issues to exclude. For example, `[1, 2]`.'
argument :label_name, [GraphQL::STRING_TYPE],
required: false,
description: 'Labels not applied to this issue.'
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 338b70bb0c6..0e9df926cdd 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -6,7 +6,7 @@ module Types
connection_type_class(Types::MergeRequestConnectionType)
- implements(Types::Notes::NoteableType)
+ implements(Types::Notes::NoteableInterface)
implements(Types::CurrentUserTodos)
authorize :read_merge_request
diff --git a/app/graphql/types/milestone_sort_enum.rb b/app/graphql/types/milestone_sort_enum.rb
new file mode 100644
index 00000000000..9f7dedb4c4c
--- /dev/null
+++ b/app/graphql/types/milestone_sort_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ class MilestoneSortEnum < SortEnum
+ graphql_name 'MilestoneSort'
+ description 'Values for sorting milestones'
+
+ value 'DUE_DATE_ASC', 'Milestone due date by ascending order.', value: :due_date_asc
+ value 'DUE_DATE_DESC', 'Milestone due date by descending order.', value: :due_date_desc
+ value 'EXPIRED_LAST_DUE_DATE_ASC', 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones without due dates and expired milestones then sort by due date in ascending order.', value: :expired_last_due_date_asc
+ value 'EXPIRED_LAST_DUE_DATE_DESC', 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones without due dates and expired milestones then sort by due date in descending order.', value: :expired_last_due_date_desc
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index eafede26c9e..27bc77b4da1 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -26,6 +26,9 @@ module Types
field :state, Types::MilestoneStateEnum, null: false,
description: 'State of the milestone.'
+ field :expired, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.'
+
field :web_path, GraphQL::STRING_TYPE, null: false, method: :milestone_path,
description: 'Web path of the milestone.'
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 6d3327f9735..df693fafbb9 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -99,11 +99,14 @@ module Types
mount_mutation Mutations::Ci::CiCdSettingsUpdate
mount_mutation Mutations::Ci::Job::Play
mount_mutation Mutations::Ci::Job::Retry
+ mount_mutation Mutations::Ci::JobTokenScope::AddProject
+ mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
mount_mutation Mutations::Ci::Runner::Update, feature_flag: :runner_graphql_query
mount_mutation Mutations::Ci::Runner::Delete, feature_flag: :runner_graphql_query
mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset, feature_flag: :runner_graphql_query
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::UserCallouts::Create
+ mount_mutation Mutations::Packages::Destroy
mount_mutation Mutations::Echo
end
end
diff --git a/app/graphql/types/noteable_type.rb b/app/graphql/types/noteable_type.rb
new file mode 100644
index 00000000000..859de86d6b8
--- /dev/null
+++ b/app/graphql/types/noteable_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ class NoteableType < BaseUnion
+ graphql_name 'NoteableType'
+ description 'Represents an object that supports notes.'
+
+ possible_types Types::IssueType, Types::DesignManagement::DesignType, Types::MergeRequestType
+
+ def self.resolve_type(object, context)
+ case object
+ when Issue
+ Types::IssueType
+ when ::DesignManagement::Design
+ Types::DesignManagement::DesignType
+ when MergeRequest
+ Types::MergeRequestType
+ else
+ raise 'Unsupported issuable type'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index 17cb4debd63..56579c357a7 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -19,6 +19,8 @@ module Types
description: "Timestamp of the discussion's creation."
field :notes, Types::Notes::NoteType.connection_type, null: false,
description: 'All notes in the discussion.'
+ field :noteable, Types::NoteableType, null: true,
+ description: 'Object which the discussion belongs to.'
# DiscussionID.coerce_result is suitable here, but will always mark this
# as being a 'Discussion'. Using `GlobalId.build` guarantees that we get
@@ -26,6 +28,14 @@ module Types
def reply_id
::Gitlab::GlobalId.build(object, id: object.reply_id)
end
+
+ def noteable
+ noteable = object.noteable
+
+ return unless Ability.allowed?(context[:current_user], :"read_#{noteable.to_ability_name}", noteable)
+
+ noteable
+ end
end
end
end
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_interface.rb
index a82a76f9c87..bd22f12d6f0 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_interface.rb
@@ -2,7 +2,7 @@
module Types
module Notes
- module NoteableType
+ module NoteableInterface
include Types::BaseInterface
field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable."
@@ -28,4 +28,4 @@ module Types
end
end
-Types::Notes::NoteableType.prepend_mod_with('Types::Notes::NoteableType')
+Types::Notes::NoteableInterface.prepend_mod_with('Types::Notes::NoteableInterface')
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 55dc73d898d..968635f9e6e 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -59,8 +59,6 @@ module Types
field :visibility, GraphQL::STRING_TYPE, null: true,
description: 'Visibility of the project.'
- field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true,
- description: 'Indicates if the project stores Docker container images in a container registry.'
field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if shared runners are enabled for the project.'
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true,
@@ -77,9 +75,15 @@ module Types
field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
description: 'URL to avatar image file of the project.'
- %i[issues merge_requests wiki snippets].each do |feature|
+ {
+ issues: "Issues are",
+ merge_requests: "Merge Requests are",
+ wiki: 'Wikis are',
+ snippets: 'Snippets are',
+ container_registry: 'Container Registry is'
+ }.each do |feature, name_string|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true,
- description: "Indicates if #{feature.to_s.titleize.pluralize} are enabled for the current user"
+ description: "Indicates if #{name_string} enabled for the current user"
define_method "#{feature}_enabled" do
object.feature_available?(feature, context[:current_user])
@@ -346,6 +350,10 @@ module Types
description: 'Find a single CI/CD template by name.',
resolver: Resolvers::Ci::TemplateResolver
+ field :ci_job_token_scope, Types::Ci::JobTokenScopeType, null: true,
+ description: 'The CI Job Tokens scope of access.',
+ resolver: Resolvers::Ci::JobTokenScopeResolver
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb
index 9948fa8bb69..027026dc16c 100644
--- a/app/graphql/types/projects/service_type_enum.rb
+++ b/app/graphql/types/projects/service_type_enum.rb
@@ -5,7 +5,7 @@ module Types
class ServiceTypeEnum < BaseEnum
graphql_name 'ServiceType'
- ::Integration.available_services_types(include_dev: false).each do |type|
+ ::Integration.available_integration_types(include_dev: false).each do |type|
value type.underscore.upcase, value: type, description: "#{type} type"
end
end
diff --git a/app/graphql/types/query_complexity_type.rb b/app/graphql/types/query_complexity_type.rb
new file mode 100644
index 00000000000..82809fac22f
--- /dev/null
+++ b/app/graphql/types/query_complexity_type.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class QueryComplexityType < ::Types::BaseObject
+ ANALYZER = GraphQL::Analysis::QueryComplexity.new { |_query, complexity| complexity }
+
+ graphql_name 'QueryComplexity'
+
+ alias_method :query, :object
+
+ field :limit, GraphQL::INT_TYPE,
+ null: true,
+ method: :max_complexity,
+ see: {
+ 'GitLab documentation on this limit' =>
+ 'https://docs.gitlab.com/ee/api/graphql/index.html#max-query-complexity'
+ },
+ description: 'GraphQL query complexity limit.'
+
+ field :score, GraphQL::INT_TYPE,
+ null: true,
+ description: 'GraphQL query complexity score.'
+
+ def score
+ ::GraphQL::Analysis.analyze_query(query, [ANALYZER]).first
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 8b7b9f0107b..d2c67aea95c 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -36,6 +36,10 @@ module Types
resolver: Resolvers::MetadataResolver,
description: 'Metadata about GitLab.'
+ field :query_complexity, Types::QueryComplexityType,
+ null: true,
+ description: 'Information about the complexity of the GraphQL query.'
+
field :snippets,
Types::SnippetType.connection_type,
null: true,
@@ -170,6 +174,10 @@ module Types
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
+
+ def query_complexity
+ context.query
+ end
end
end
diff --git a/app/graphql/types/release_asset_link_type.rb b/app/graphql/types/release_asset_link_type.rb
index c27e1cf19b3..829e7e246db 100644
--- a/app/graphql/types/release_asset_link_type.rb
+++ b/app/graphql/types/release_asset_link_type.rb
@@ -20,6 +20,8 @@ module Types
field :direct_asset_url, GraphQL::STRING_TYPE, null: true,
description: 'Direct asset URL of the link.'
+ field :direct_asset_path, GraphQL::STRING_TYPE, null: true, method: :filepath,
+ description: 'Relative path for the direct asset link.'
def direct_asset_url
return object.url unless object.filepath
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 34357ead048..7606bdbc46d 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'Snippet'
description 'Represents a snippet entry'
- implements(Types::Notes::NoteableType)
+ implements(Types::Notes::NoteableInterface)
present_using SnippetPresenter
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index fb9ee380705..1335838935e 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -16,6 +16,10 @@ module Types
description: 'Blob plain highlighted data.',
null: true
+ field :raw_plain_data, GraphQL::STRING_TYPE,
+ description: 'The raw content of the blob, if the blob is text data.',
+ null: true
+
field :raw_path, GraphQL::STRING_TYPE,
description: 'Blob raw content endpoint path.',
null: false
diff --git a/app/graphql/types/user_callout_type.rb b/app/graphql/types/user_callout_type.rb
index 12f4fdea878..0ff32d68400 100644
--- a/app/graphql/types/user_callout_type.rb
+++ b/app/graphql/types/user_callout_type.rb
@@ -4,7 +4,7 @@ module Types
class UserCalloutType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
graphql_name 'UserCallout'
- field :feature_name, UserCalloutFeatureNameEnum, null: false,
+ field :feature_name, UserCalloutFeatureNameEnum, null: true,
description: 'Name of the feature that the callout is for.'
field :dismissed_at, Types::TimeType, null: true,
description: 'Date when the callout was dismissed.'
diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb
index cd520a75b44..5719d8f5ffd 100644
--- a/app/helpers/admin/user_actions_helper.rb
+++ b/app/helpers/admin/user_actions_helper.rb
@@ -15,6 +15,7 @@ module Admin
deactivate_actions
unlock_actions
delete_actions
+ ban_actions
@actions
end
@@ -28,7 +29,7 @@ module Admin
@actions << 'approve'
@actions << 'reject'
elsif @user.blocked?
- @actions << 'unblock'
+ @actions << 'unblock' unless @user.banned?
else
@actions << 'block'
end
@@ -52,5 +53,19 @@ module Admin
@actions << 'delete'
@actions << 'delete_with_contributions'
end
+
+ def ban_actions
+ return unless ban_feature_available?
+ return if @user.internal?
+
+ if @user.banned?
+ @actions << 'unban'
+ return
+ end
+
+ unless @user.blocked?
+ @actions << 'ban'
+ end
+ end
end
end
diff --git a/app/helpers/analytics/unique_visits_helper.rb b/app/helpers/analytics/unique_visits_helper.rb
deleted file mode 100644
index 4aa8907f578..00000000000
--- a/app/helpers/analytics/unique_visits_helper.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Analytics
- module UniqueVisitsHelper
- extend ActiveSupport::Concern
-
- def visitor_id
- return cookies[:visitor_id] if cookies[:visitor_id].present?
- return unless current_user
-
- uuid = SecureRandom.uuid
- cookies[:visitor_id] = { value: uuid, expires: 24.months }
- uuid
- end
-
- def track_visit(target_id)
- return unless visitor_id
-
- Gitlab::Analytics::UniqueVisits.new.track_visit(target_id, values: visitor_id)
- end
-
- class_methods do
- def track_unique_visits(controller_actions, target_id:)
- after_action only: controller_actions, if: -> { request.format.html? && request.headers['DNT'] != '1' } do
- track_visit(target_id)
- end
- end
- end
- end
-end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2e15b3f22c2..1304bcb1c7e 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -285,7 +285,7 @@ module ApplicationHelper
def page_class
class_names = []
class_names << 'issue-boards-page gl-overflow-auto' if current_controller?(:boards)
- class_names << 'epic-boards-page' if current_controller?(:epic_boards)
+ class_names << 'epic-boards-page gl-overflow-auto' if current_controller?(:epic_boards)
class_names << 'environment-logs-page' if current_controller?(:logs)
class_names << 'with-performance-bar' if performance_bar_enabled?
class_names << system_message_class
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index efdad22fa54..a3df566e4b3 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -331,6 +331,7 @@ module ApplicationSettingsHelper
:unique_ips_limit_per_user,
:unique_ips_limit_time_window,
:usage_ping_enabled,
+ :usage_ping_features_enabled,
:user_default_external,
:user_show_add_ssh_key_message,
:user_default_internal_regex,
@@ -343,6 +344,8 @@ module ApplicationSettingsHelper
:commit_email_hostname,
:protected_ci_variables,
:local_markdown_version,
+ :mailgun_signing_key,
+ :mailgun_events_enabled,
:snowplow_collector_hostname,
:snowplow_cookie_domain,
:snowplow_enabled,
@@ -437,6 +440,10 @@ module ApplicationSettingsHelper
Feature.enabled?(:help_page_documentation_redirect)
end
+ def valid_runner_registrars
+ Gitlab::CurrentSettings.valid_runner_registrars
+ end
+
def signup_enabled?
!!Gitlab::CurrentSettings.signup_enabled
end
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index c27f5d4ebce..91a335cd504 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -7,7 +7,7 @@ module AutoDevopsHelper
can?(current_user, :admin_pipeline, project) &&
project.has_auto_devops_implicitly_disabled? &&
!project.repository.gitlab_ci_yml &&
- !project.ci_service
+ !project.ci_integration
end
def badge_for_auto_devops_scope(auto_devops_receiver)
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index dfd6de3f1d5..eccd0e7a34c 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -65,7 +65,7 @@ module BlobHelper
return unless blob = readable_blob(options, path, project, ref)
common_classes = "btn gl-button btn-confirm js-edit-blob gl-ml-3 #{options[:extra_class]}"
- data = { track_event: 'click_edit', track_label: 'Edit' }
+ data = { track_action: 'click_edit', track_label: 'edit' }
if Feature.enabled?(:web_ide_primary_edit, project.group)
common_classes += " btn-inverted"
@@ -85,7 +85,7 @@ module BlobHelper
return unless blob
common_classes = 'btn gl-button btn-confirm ide-edit-button gl-ml-3'
- data = { track_event: 'click_edit_ide', track_label: 'Web IDE' }
+ data = { track_action: 'click_edit_ide', track_label: 'web_ide' }
unless Feature.enabled?(:web_ide_primary_edit, project.group)
common_classes += " btn-inverted"
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 23f2a082a68..882302f05ad 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -9,7 +9,6 @@ module Ci
"artifact_help_url" => help_page_path('user/gitlab_com/index.html', anchor: 'gitlab-cicd'),
"deployment_help_url" => help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting'),
"runner_settings_url" => project_runners_path(@build.project, anchor: 'js-runners-settings'),
- "variables_settings_url" => project_variables_path(@build.project, anchor: 'js-cicd-variables-settings'),
"page_path" => project_job_path(@project, @build),
"build_status" => @build.status,
"build_stage" => @build.stage,
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index 8c8ee2d4d0f..d441ffbb853 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -9,24 +9,26 @@ module Ci
end
def js_pipeline_editor_data(project)
- commit_sha = project.commit ? project.commit.sha : ''
+ initial_branch = params[:branch_name]
+ latest_commit = project.repository.commit(initial_branch) || project.commit
+ commit_sha = latest_commit ? latest_commit.sha : ''
{
"ci-config-path": project.ci_config_path_or_default,
- "ci-examples-help-page-path" => help_page_path('ci/examples/README'),
- "ci-help-page-path" => help_page_path('ci/README'),
+ "ci-examples-help-page-path" => help_page_path('ci/examples/index'),
+ "ci-help-page-path" => help_page_path('ci/index'),
"commit-sha" => commit_sha,
- "default-branch" => project.default_branch,
+ "default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
- "initial-branch-name": params[:branch_name],
+ "initial-branch-name" => initial_branch,
"lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
"needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'),
"new-merge-request-path" => namespace_project_new_merge_request_path,
- "pipeline_etag" => project.commit ? graphql_etag_pipeline_sha_path(commit_sha) : '',
+ "pipeline_etag" => latest_commit ? graphql_etag_pipeline_sha_path(commit_sha) : '',
"pipeline-page-path" => project_pipelines_path(project),
"project-path" => project.path,
"project-full-path" => project.full_path,
"project-namespace" => project.namespace.full_path,
- "runner-help-page-path" => help_page_path('ci/runners/README'),
+ "runner-help-page-path" => help_page_path('ci/runners/index'),
"total-branches" => project.repository.branches.length,
"yml-help-page-path" => help_page_path('ci/yaml/README')
}
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index f42cd53ae3a..6be46b40023 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -30,42 +30,40 @@ module Ci
project.has_ci? && project.builds_enabled?
end
- # This list of templates is for the pipeline_empty_state_templates experiment
- # and will be cleaned up with https://gitlab.com/gitlab-org/gitlab/-/issues/326299
- def experiment_suggested_ci_templates
+ def suggested_ci_templates
[
- { name: 'Android', logo: image_path('illustrations/logos/android.svg') },
- { name: 'Bash', logo: image_path('illustrations/logos/bash.svg') },
- { name: 'C++', logo: image_path('illustrations/logos/c_plus_plus.svg') },
- { name: 'Clojure', logo: image_path('illustrations/logos/clojure.svg') },
- { name: 'Composer', logo: image_path('illustrations/logos/composer.svg') },
- { name: 'Crystal', logo: image_path('illustrations/logos/crystal.svg') },
- { name: 'Dart', logo: image_path('illustrations/logos/dart.svg') },
- { name: 'Django', logo: image_path('illustrations/logos/django.svg') },
- { name: 'Docker', logo: image_path('illustrations/logos/docker.svg') },
- { name: 'Elixir', logo: image_path('illustrations/logos/elixir.svg') },
- { name: 'iOS-Fastlane', logo: image_path('illustrations/logos/fastlane.svg') },
- { name: 'Flutter', logo: image_path('illustrations/logos/flutter.svg') },
- { name: 'Go', logo: image_path('illustrations/logos/go_logo.svg') },
- { name: 'Gradle', logo: image_path('illustrations/logos/gradle.svg') },
- { name: 'Grails', logo: image_path('illustrations/logos/grails.svg') },
- { name: 'dotNET', logo: image_path('illustrations/logos/dotnet.svg') },
- { name: 'Julia', logo: image_path('illustrations/logos/julia.svg') },
- { name: 'Laravel', logo: image_path('illustrations/logos/laravel.svg') },
- { name: 'LaTeX', logo: image_path('illustrations/logos/latex.svg') },
- { name: 'Maven', logo: image_path('illustrations/logos/maven.svg') },
- { name: 'Mono', logo: image_path('illustrations/logos/mono.svg') },
- { name: 'Nodejs', logo: image_path('illustrations/logos/node_js.svg') },
- { name: 'npm', logo: image_path('illustrations/logos/npm.svg') },
- { name: 'OpenShift', logo: image_path('illustrations/logos/openshift.svg') },
- { name: 'Packer', logo: image_path('illustrations/logos/packer.svg') },
- { name: 'PHP', logo: image_path('illustrations/logos/php.svg') },
- { name: 'Python', logo: image_path('illustrations/logos/python.svg') },
- { name: 'Ruby', logo: image_path('illustrations/logos/ruby.svg') },
- { name: 'Rust', logo: image_path('illustrations/logos/rust.svg') },
- { name: 'Scala', logo: image_path('illustrations/logos/scala.svg') },
- { name: 'Swift', logo: image_path('illustrations/logos/swift.svg') },
- { name: 'Terraform', logo: image_path('illustrations/logos/terraform.svg') }
+ { name: 'Android', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/android.svg') },
+ { name: 'Bash', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/bash.svg') },
+ { name: 'C++', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/c_plus_plus.svg') },
+ { name: 'Clojure', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/clojure.svg') },
+ { name: 'Composer', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/composer.svg') },
+ { name: 'Crystal', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/crystal.svg') },
+ { name: 'Dart', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dart.svg') },
+ { name: 'Django', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/django.svg') },
+ { name: 'Docker', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/docker.svg') },
+ { name: 'Elixir', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/elixir.svg') },
+ { name: 'iOS-Fastlane', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/fastlane.svg') },
+ { name: 'Flutter', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/flutter.svg') },
+ { name: 'Go', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/go_logo.svg') },
+ { name: 'Gradle', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/gradle.svg') },
+ { name: 'Grails', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/grails.svg') },
+ { name: 'dotNET', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/dotnet.svg') },
+ { name: 'Julia', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/julia.svg') },
+ { name: 'Laravel', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/laravel.svg') },
+ { name: 'LaTeX', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/latex.svg') },
+ { name: 'Maven', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/maven.svg') },
+ { name: 'Mono', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/mono.svg') },
+ { name: 'Nodejs', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/node_js.svg') },
+ { name: 'npm', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/npm.svg') },
+ { name: 'OpenShift', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/openshift.svg') },
+ { name: 'Packer', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/packer.svg') },
+ { name: 'PHP', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/php.svg') },
+ { name: 'Python', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/python.svg') },
+ { name: 'Ruby', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/ruby.svg') },
+ { name: 'Rust', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/rust.svg') },
+ { name: 'Scala', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/scala.svg') },
+ { name: 'Swift', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/swift.svg') },
+ { name: 'Terraform', logo: image_path('illustrations/third-party-logos/ci_cd-template-logos/terraform.svg') }
]
end
diff --git a/app/helpers/ci/variables_helper.rb b/app/helpers/ci/variables_helper.rb
index b20390d58e9..84572363a8d 100644
--- a/app/helpers/ci/variables_helper.rb
+++ b/app/helpers/ci/variables_helper.rb
@@ -48,7 +48,7 @@ module Ci
end
def ci_variable_maskable_regex
- Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/')
+ Ci::Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(%r{^/}, '').sub(%r{/[a-z]*$}, '').gsub('\/', '/')
end
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 14783882f5e..e9a75babb97 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -20,7 +20,11 @@ module ClustersHelper
{
default_branch_name: clusterable_project.default_branch,
empty_state_image: image_path('illustrations/clusters_empty.svg'),
- project_path: clusterable_project.full_path
+ project_path: clusterable_project.full_path,
+ agent_docs_url: help_page_path('user/clusters/agent/index'),
+ install_docs_url: help_page_path('administration/clusters/kas'),
+ get_started_docs_url: help_page_path('user/clusters/agent/index', anchor: 'define-a-configuration-repository'),
+ integration_docs_url: help_page_path('user/clusters/agent/index', anchor: 'get-started-with-gitops-and-the-gitlab-agent')
}
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index a7696ba4ea7..d2ac1e8f985 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -167,6 +167,14 @@ module CommitsHelper
]
end
+ DEFAULT_SHA = '0000000'
+
+ # Returns the template path for commit resources
+ # to be utilized by the client applications.
+ def commit_path_template(project)
+ project_commit_path(project, DEFAULT_SHA).sub("/#{DEFAULT_SHA}", '/$COMMIT_SHA')
+ end
+
protected
# Private: Returns a link to a person. If the person has a matching user and
diff --git a/app/helpers/custom_metrics_helper.rb b/app/helpers/custom_metrics_helper.rb
index 5ea386e268d..9fbfe377c61 100644
--- a/app/helpers/custom_metrics_helper.rb
+++ b/app/helpers/custom_metrics_helper.rb
@@ -5,7 +5,7 @@ module CustomMetricsHelper
{
'custom-metrics-path' => url_for([project, metric]),
'metric-persisted' => metric.persisted?.to_s,
- 'edit-project-service-path' => edit_project_service_path(project, PrometheusService),
+ 'edit-project-service-path' => edit_project_service_path(project, ::Integrations::Prometheus),
'validate-query-path' => validate_query_project_prometheus_metrics_path(project),
'title' => metric.title.to_s,
'query' => metric.query.to_s,
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index e430b0f402b..3aa54e3afe9 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -190,10 +190,8 @@ module DiffHelper
end
def render_overflow_warning?(diffs_collection)
- diff_files = diffs_collection.raw_diff_files
-
- diff_files.overflow?.tap do |overflown|
- log_overflow_limits(diff_files)
+ diffs_collection.overflow?.tap do |overflown|
+ log_overflow_limits(diff_files: diffs_collection.raw_diff_files, collection_overflow: overflown)
end
end
@@ -285,12 +283,12 @@ module DiffHelper
conflicts_service.conflicts.files.index_by(&:our_path)
end
- def log_overflow_limits(diff_files)
+ def log_overflow_limits(diff_files:, collection_overflow:)
if diff_files.any?(&:too_large?)
Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
end
- Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if diff_files.overflow?
+ Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if collection_overflow
Gitlab::Metrics.add_event(:diffs_overflow_max_bytes_limits) if diff_files.overflow_max_bytes?
Gitlab::Metrics.add_event(:diffs_overflow_max_files_limits) if diff_files.overflow_max_files?
Gitlab::Metrics.add_event(:diffs_overflow_max_lines_limits) if diff_files.overflow_max_lines?
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 5927c82abe9..62060200698 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -79,7 +79,7 @@ module EnvironmentsHelper
end
def has_managed_prometheus?(project)
- project.prometheus_service&.prometheus_available? == true
+ project.prometheus_integration&.prometheus_available? == true
end
def metrics_dashboard_base_path(environment, project)
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index cd70c6f3962..4ee3acd32d2 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -240,8 +240,7 @@ module EventsHelper
DESIGN_ICONS = {
'created' => 'upload',
'updated' => 'pencil',
- 'destroyed' => ICON_NAMES_BY_EVENT_TYPE['destroyed'],
- 'archived' => 'archive'
+ 'destroyed' => ICON_NAMES_BY_EVENT_TYPE['destroyed']
}.freeze
def design_event_icon(action, size: 24)
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 0a684d92eb1..0f835e6881e 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -7,381 +7,18 @@ module GitlabRoutingHelper
include ::ProjectsHelper
include ::ApplicationSettingsHelper
include API::Helpers::RelatedResourcesHelpers
+ include ::Routing::ProjectsHelper
+ include ::Routing::Projects::MembersHelper
+ include ::Routing::Groups::MembersHelper
+ include ::Routing::MembersHelper
+ include ::Routing::ArtifactsHelper
+ include ::Routing::PipelineSchedulesHelper
+ include ::Routing::SnippetsHelper
+ include ::Routing::WikiHelper
+ include ::Routing::GraphqlHelper
included do
Gitlab::Routing.includes_helpers(self)
end
-
- # Project
- def project_tree_path(project, ref = nil, *args)
- namespace_project_tree_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
- end
-
- def project_commits_path(project, ref = nil, *args)
- namespace_project_commits_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
- end
-
- def project_ref_path(project, ref_name, *args)
- project_commits_path(project, ref_name, *args)
- end
-
- def environment_path(environment, *args)
- project_environment_path(environment.project, environment, *args)
- end
-
- def environment_metrics_path(environment, *args)
- metrics_project_environment_path(environment.project, environment, *args)
- end
-
- def environment_delete_path(environment, *args)
- expose_path(api_v4_projects_environments_path(id: environment.project.id, environment_id: environment.id))
- end
-
- def issue_path(entity, *args)
- project_issue_path(entity.project, entity, *args)
- end
-
- def merge_request_path(entity, *args)
- project_merge_request_path(entity.project, entity, *args)
- end
-
- def pipeline_path(pipeline, *args)
- project_pipeline_path(pipeline.project, pipeline.id, *args)
- end
-
- def issue_url(entity, *args)
- project_issue_url(entity.project, entity, *args)
- end
-
- def merge_request_url(entity, *args)
- project_merge_request_url(entity.project, entity, *args)
- end
-
- def pipeline_url(pipeline, *args)
- project_pipeline_url(pipeline.project, pipeline.id, *args)
- end
-
- def pipeline_job_url(pipeline, build, *args)
- project_job_url(pipeline.project, build.id, *args)
- end
-
- def commits_url(entity, *args)
- project_commits_url(entity.project, entity.source_ref, *args)
- end
-
- def commit_url(entity, *args)
- project_commit_url(entity.project, entity.sha, *args)
- end
-
- def release_url(entity, *args)
- project_release_url(entity.project, entity, *args)
- end
-
- def preview_markdown_path(parent, *args)
- return group_preview_markdown_path(parent, *args) if parent.is_a?(Group)
-
- if @snippet.is_a?(PersonalSnippet)
- preview_markdown_snippets_path
- else
- preview_markdown_project_path(parent, *args)
- end
- end
-
- def edit_milestone_path(entity, *args)
- if entity.resource_parent.is_a?(Group)
- edit_group_milestone_path(entity.resource_parent, entity, *args)
- else
- edit_project_milestone_path(entity.resource_parent, entity, *args)
- end
- end
-
- def toggle_subscription_path(entity, *args)
- if entity.is_a?(Issue)
- toggle_subscription_project_issue_path(entity.project, entity)
- else
- toggle_subscription_project_merge_request_path(entity.project, entity)
- end
- end
-
- def toggle_award_emoji_personal_snippet_path(*args)
- toggle_award_emoji_snippet_path(*args)
- end
-
- def toggle_award_emoji_project_project_snippet_path(*args)
- toggle_award_emoji_project_snippet_path(*args)
- end
-
- def toggle_award_emoji_project_project_snippet_url(*args)
- toggle_award_emoji_project_snippet_url(*args)
- end
-
- ## Members
- def project_members_url(project, *args)
- project_project_members_url(project, *args)
- end
-
- def project_member_path(project_member, *args)
- project_project_member_path(project_member.source, project_member)
- end
-
- def request_access_project_members_path(project, *args)
- request_access_project_project_members_path(project)
- end
-
- def leave_project_members_path(project, *args)
- leave_project_project_members_path(project)
- end
-
- def approve_access_request_project_member_path(project_member, *args)
- approve_access_request_project_project_member_path(project_member.source, project_member)
- end
-
- def resend_invite_project_member_path(project_member, *args)
- resend_invite_project_project_member_path(project_member.source, project_member)
- end
-
- # Groups
-
- ## Members
- def group_members_url(group, *args)
- group_group_members_url(group, *args)
- end
-
- def group_member_path(group_member, *args)
- group_group_member_path(group_member.source, group_member)
- end
-
- def request_access_group_members_path(group, *args)
- request_access_group_group_members_path(group)
- end
-
- def leave_group_members_path(group, *args)
- leave_group_group_members_path(group)
- end
-
- def approve_access_request_group_member_path(group_member, *args)
- approve_access_request_group_group_member_path(group_member.source, group_member)
- end
-
- def resend_invite_group_member_path(group_member, *args)
- resend_invite_group_group_member_path(group_member.source, group_member)
- end
-
- # Members
- def source_members_url(member)
- case member.source_type
- when 'Namespace'
- group_group_members_url(member.source)
- when 'Project'
- project_project_members_url(member.source)
- end
- end
-
- # Artifacts
-
- # Rails path generators are slow because they need to do large regex comparisons
- # against the arguments. We can speed this up 10x by generating the strings directly.
-
- # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format)
- def fast_download_project_job_artifacts_path(project, job, params = {})
- expose_fast_artifacts_path(project, job, :download, params)
- end
-
- # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format)
- def fast_keep_project_job_artifacts_path(project, job)
- expose_fast_artifacts_path(project, job, :keep)
- end
-
- # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/browse(/*path)
- def fast_browse_project_job_artifacts_path(project, job)
- expose_fast_artifacts_path(project, job, :browse)
- end
-
- def expose_fast_artifacts_path(project, job, action, params = {})
- path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}"
-
- unless params.empty?
- path += "?#{params.to_query}"
- end
-
- Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path)
- end
-
- def artifacts_action_path(path, project, build)
- action, path_params = path.split('/', 2)
- args = [project, build, path_params]
-
- case action
- when 'download'
- download_project_job_artifacts_path(*args)
- when 'browse'
- browse_project_job_artifacts_path(*args)
- when 'file'
- file_project_job_artifacts_path(*args)
- when 'raw'
- raw_project_job_artifacts_path(*args)
- end
- end
-
- # Pipeline Schedules
- def pipeline_schedules_path(project, *args)
- project_pipeline_schedules_path(project, *args)
- end
-
- def pipeline_schedule_path(schedule, *args)
- project = schedule.project
- project_pipeline_schedule_path(project, schedule, *args)
- end
-
- def edit_pipeline_schedule_path(schedule)
- project = schedule.project
- edit_project_pipeline_schedule_path(project, schedule)
- end
-
- def play_pipeline_schedule_path(schedule, *args)
- project = schedule.project
- play_project_pipeline_schedule_path(project, schedule, *args)
- end
-
- def take_ownership_pipeline_schedule_path(schedule, *args)
- project = schedule.project
- take_ownership_project_pipeline_schedule_path(project, schedule, *args)
- end
-
- def gitlab_snippet_path(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
- project_snippet_path(snippet.project, snippet, *args)
- else
- new_args = snippet_query_params(snippet, *args)
- snippet_path(snippet, *new_args)
- end
- end
-
- def gitlab_snippet_url(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
- project_snippet_url(snippet.project, snippet, *args)
- else
- new_args = snippet_query_params(snippet, *args)
- snippet_url(snippet, *new_args)
- end
- end
-
- def gitlab_dashboard_snippets_path(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
- project_snippets_path(snippet.project, *args)
- else
- dashboard_snippets_path
- end
- end
-
- def gitlab_raw_snippet_path(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
- raw_project_snippet_path(snippet.project, snippet, *args)
- else
- new_args = snippet_query_params(snippet, *args)
- raw_snippet_path(snippet, *new_args)
- end
- end
-
- def gitlab_raw_snippet_url(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
- raw_project_snippet_url(snippet.project, snippet, *args)
- else
- new_args = snippet_query_params(snippet, *args)
- raw_snippet_url(snippet, *new_args)
- end
- end
-
- def gitlab_raw_snippet_blob_url(snippet, path, ref = nil, **options)
- params = {
- snippet_id: snippet,
- ref: ref || snippet.repository.root_ref,
- path: path
- }
-
- if snippet.is_a?(ProjectSnippet)
- project_snippet_blob_raw_url(snippet.project, **params, **options)
- else
- snippet_blob_raw_url(**params, **options)
- end
- end
-
- def gitlab_raw_snippet_blob_path(snippet, path, ref = nil, **options)
- gitlab_raw_snippet_blob_url(snippet, path, ref, only_path: true, **options)
- end
-
- def gitlab_snippet_notes_path(snippet, *args)
- new_args = snippet_query_params(snippet, *args)
- snippet_notes_path(snippet, *new_args)
- end
-
- def gitlab_snippet_notes_url(snippet, *args)
- new_args = snippet_query_params(snippet, *args)
- snippet_notes_url(snippet, *new_args)
- end
-
- def gitlab_snippet_note_path(snippet, note, *args)
- new_args = snippet_query_params(snippet, *args)
- snippet_note_path(snippet, note, *new_args)
- end
-
- def gitlab_snippet_note_url(snippet, note, *args)
- new_args = snippet_query_params(snippet, *args)
- snippet_note_url(snippet, note, *new_args)
- end
-
- def gitlab_toggle_award_emoji_snippet_note_path(snippet, note, *args)
- new_args = snippet_query_params(snippet, *args)
- toggle_award_emoji_snippet_note_path(snippet, note, *new_args)
- end
-
- def gitlab_toggle_award_emoji_snippet_note_url(snippet, note, *args)
- new_args = snippet_query_params(snippet, *args)
- toggle_award_emoji_snippet_note_url(snippet, note, *new_args)
- end
-
- def gitlab_toggle_award_emoji_snippet_path(snippet, *args)
- new_args = snippet_query_params(snippet, *args)
- toggle_award_emoji_snippet_path(snippet, *new_args)
- end
-
- def gitlab_toggle_award_emoji_snippet_url(snippet, *args)
- new_args = snippet_query_params(snippet, *args)
- toggle_award_emoji_snippet_url(snippet, *new_args)
- end
-
- # Wikis
-
- def wiki_path(wiki, **options)
- Gitlab::UrlBuilder.wiki_url(wiki, only_path: true, **options)
- end
-
- def wiki_page_path(wiki, page, **options)
- Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
- end
-
- # GraphQL ETag routes
- def graphql_etag_pipeline_path(pipeline)
- [api_graphql_path, "pipelines/id/#{pipeline.id}"].join(':')
- end
-
- def graphql_etag_pipeline_sha_path(sha)
- [api_graphql_path, "pipelines/sha/#{sha}"].join(':')
- end
-
- private
-
- def snippet_query_params(snippet, *args)
- opts = case args.last
- when Hash
- args.pop
- when ActionController::Parameters
- args.pop.to_h
- else
- {}
- end
-
- args << opts
- end
end
GitlabRoutingHelper.include_mod_with('GitlabRoutingHelper')
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 26a5df321cd..400ad721b06 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -5,15 +5,9 @@ module GroupsHelper
%w[
groups#activity
groups#subgroups
- ].tap do |paths|
- extra_routes = if sidebar_refactor_disabled?
- ['groups#show', 'groups#details']
- else
- ['labels#index', 'group_members#index']
- end
-
- paths.concat(extra_routes)
- end
+ labels#index
+ group_members#index
+ ]
end
def group_settings_nav_link_paths
@@ -45,11 +39,7 @@ module GroupsHelper
end
def group_information_title(group)
- if Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml)
- group.subgroup? ? _('Subgroup information') : _('Group information')
- else
- group.subgroup? ? _('Subgroup overview') : _('Group overview')
- end
+ group.subgroup? ? _('Subgroup information') : _('Group information')
end
def group_container_registry_nav?
diff --git a/app/helpers/services_helper.rb b/app/helpers/integrations_helper.rb
index 83000189ab3..ab305d822e8 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -1,36 +1,18 @@
# frozen_string_literal: true
-module ServicesHelper
- def service_event_description(event)
- case event
- when "push", "push_events"
- s_("ProjectService|Trigger event for pushes to the repository.")
- when "tag_push", "tag_push_events"
- s_("ProjectService|Trigger event for new tags pushed to the repository.")
- when "note", "note_events"
- s_("ProjectService|Trigger event for new comments.")
- when "confidential_note", "confidential_note_events"
- s_("ProjectService|Trigger event for new comments on confidential issues.")
- when "issue", "issue_events"
- s_("ProjectService|Trigger event when an issue is created, updated, or closed.")
- when "confidential_issue", "confidential_issue_events"
- s_("ProjectService|Trigger event when a confidential issue is created, updated, or closed.")
- when "merge_request", "merge_request_events"
- s_("ProjectService|Trigger event when a merge request is created, updated, or merged.")
- when "pipeline", "pipeline_events"
- s_("ProjectService|Trigger event when a pipeline status changes.")
- when "wiki_page", "wiki_page_events"
- s_("ProjectService|Trigger event when a wiki page is created or updated.")
- when "commit", "commit_events"
- s_("ProjectService|Trigger event when a commit is created or updated.")
- when "deployment"
- s_("ProjectService|Trigger event when a deployment starts or finishes.")
- when "alert"
- s_("ProjectService|Trigger event when a new, unique alert is recorded.")
+module IntegrationsHelper
+ def integration_event_description(integration, event)
+ case integration
+ when Integrations::Jira
+ jira_integration_event_description(event)
+ when Integrations::Teamcity
+ teamcity_integration_event_description(event)
+ else
+ default_integration_event_description(event)
end
end
- def service_event_field_name(event)
+ def integration_event_field_name(event)
event = event.pluralize if %w[merge_request issue confidential_issue].include?(event)
"#{event}_events"
end
@@ -96,13 +78,13 @@ module ServicesHelper
enable_comments: integration.comment_on_event_enabled.to_s,
comment_detail: integration.comment_detail,
learn_more_path: integrations_help_page_path,
- trigger_events: trigger_events_for_service(integration),
- fields: fields_for_service(integration),
+ trigger_events: trigger_events_for_integration(integration),
+ fields: fields_for_integration(integration),
inherit_from_id: integration.inherit_from_id,
integration_level: integration_level(integration),
editable: integration.editable?.to_s,
cancel_path: scoped_integrations_path,
- can_test: integration.can_test?.to_s,
+ can_test: integration.testable?.to_s,
test_path: scoped_test_integration_path(integration),
reset_path: scoped_reset_integration_path(integration, group: group)
}
@@ -121,14 +103,6 @@ module ServicesHelper
}
end
- def trigger_events_for_service(integration)
- ServiceEventSerializer.new(service: integration).represent(integration.configurable_events).to_json
- end
-
- def fields_for_service(integration)
- ServiceFieldSerializer.new(service: integration).represent(integration.global_fields).to_json
- end
-
def integrations_help_page_path
help_page_path('user/admin_area/settings/project_integration_management')
end
@@ -152,6 +126,61 @@ module ServicesHelper
private
+ def jira_integration_event_description(event)
+ case event
+ when "merge_request", "merge_request_events"
+ s_("JiraService|Jira comments are created when an issue is referenced in a merge request.")
+ when "commit", "commit_events"
+ s_("JiraService|Jira comments are created when an issue is referenced in a commit.")
+ end
+ end
+
+ def teamcity_integration_event_description(event)
+ case event
+ when 'push', 'push_events'
+ s_('TeamcityIntegration|Trigger TeamCity CI after every push to the repository, except branch delete')
+ when 'merge_request', 'merge_request_events'
+ s_('TeamcityIntegration|Trigger TeamCity CI after a merge request has been created or updated')
+ end
+ end
+
+ def default_integration_event_description(event)
+ case event
+ when "push", "push_events"
+ s_("ProjectService|Trigger event for pushes to the repository.")
+ when "tag_push", "tag_push_events"
+ s_("ProjectService|Trigger event for new tags pushed to the repository.")
+ when "note", "note_events"
+ s_("ProjectService|Trigger event for new comments.")
+ when "confidential_note", "confidential_note_events"
+ s_("ProjectService|Trigger event for new comments on confidential issues.")
+ when "issue", "issue_events"
+ s_("ProjectService|Trigger event when an issue is created, updated, or closed.")
+ when "confidential_issue", "confidential_issue_events"
+ s_("ProjectService|Trigger event when a confidential issue is created, updated, or closed.")
+ when "merge_request", "merge_request_events"
+ s_("ProjectService|Trigger event when a merge request is created, updated, or merged.")
+ when "pipeline", "pipeline_events"
+ s_("ProjectService|Trigger event when a pipeline status changes.")
+ when "wiki_page", "wiki_page_events"
+ s_("ProjectService|Trigger event when a wiki page is created or updated.")
+ when "commit", "commit_events"
+ s_("ProjectService|Trigger event when a commit is created or updated.")
+ when "deployment"
+ s_("ProjectService|Trigger event when a deployment starts or finishes.")
+ when "alert"
+ s_("ProjectService|Trigger event when a new, unique alert is recorded.")
+ end
+ end
+
+ def trigger_events_for_integration(integration)
+ ServiceEventSerializer.new(service: integration).represent(integration.configurable_events).to_json
+ end
+
+ def fields_for_integration(integration)
+ ServiceFieldSerializer.new(service: integration).represent(integration.global_fields).to_json
+ end
+
def integration_level(integration)
if integration.instance_level?
'instance'
@@ -172,14 +201,10 @@ module ServicesHelper
name: integration.to_param
}
end
-
- def show_service_templates_nav_link?
- Feature.disabled?(:disable_service_templates, type: :development, default_enabled: :yaml)
- end
end
-ServicesHelper.prepend_mod_with('ServicesHelper')
+IntegrationsHelper.prepend_mod_with('IntegrationsHelper')
-# The methods in `EE::ServicesHelper` should be available as both instance and
+# The methods in `EE::IntegrationsHelper` should be available as both instance and
# class methods.
-ServicesHelper.extend_mod_with('ServicesHelper')
+IntegrationsHelper.extend_mod_with('IntegrationsHelper')
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index c40feb42eea..d8ba530f3f6 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -425,6 +425,15 @@ module IssuablesHelper
}
end
+ def sidebar_status_data(issuable_sidebar, project)
+ {
+ iid: issuable_sidebar[:iid],
+ issuable_type: issuable_sidebar[:type],
+ full_path: project.full_path,
+ can_edit: issuable_sidebar.dig(:current_user, :can_edit).to_s
+ }
+ end
+
def parent
@project || @group
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 7690773354f..5bedfc61d46 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -181,7 +181,6 @@ module IssuesHelper
def issues_list_data(project, current_user, finder)
{
- autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json),
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
can_bulk_update: can?(current_user, :admin_issue, project).to_s,
@@ -201,8 +200,6 @@ module IssuesHelper
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project),
- project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
- project_milestones_path: project_milestones_path(project, format: :json),
project_path: project.full_path,
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 39a8f506ba2..106df168080 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -56,10 +56,6 @@ module NamespacesHelper
namespaces_options(selected, **options)
end
- def cascading_namespace_settings_enabled?
- NamespaceSetting.cascading_settings_feature_enabled?
- end
-
def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index b952aeacb13..ff8839d68fd 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -21,13 +21,6 @@ module Nav
}
end
- def new_repo_experiment_text
- experiment(:new_repo, user: current_user) do |e|
- e.use { _('New project') }
- e.try { _('New project/repository') }
- end.run
- end
-
private
def group_menu_section(group)
@@ -37,9 +30,9 @@ module Nav
menu_items.push(
::Gitlab::Nav::TopNavMenuItem.build(
id: 'new_project',
- title: new_repo_experiment_text,
+ title: _('New project/repository'),
href: new_project_path(namespace_id: group.id),
- data: { track_experiment: 'new_repo', track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
+ data: { track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -129,9 +122,9 @@ module Nav
menu_items.push(
::Gitlab::Nav::TopNavMenuItem.build(
id: 'general_new_project',
- title: new_repo_experiment_text,
+ title: _('New project/repository'),
href: new_project_path,
- data: { track_experiment: 'new_repo', track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
+ data: { track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
)
)
end
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index b8ddb932b73..052b8339ebd 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -22,7 +22,7 @@ module Nav
new_view_model = new_dropdown_view_model(project: project, group: group)
- if new_view_model
+ if new_view_model && new_view_model.fetch(:menu_sections)&.any?
builder.add_view(NEW_VIEW, new_view_model)
end
@@ -98,7 +98,7 @@ module Nav
builder.add_primary_menu_item_with_shortcut(
active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
css_class: 'qa-projects-dropdown',
- data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
+ data: { track_label: "projects_dropdown", track_event: "click_dropdown" },
view: PROJECTS_VIEW,
shortcut_href: dashboard_projects_path,
**projects_menu_item_attrs
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index b5171dfbebd..6c57a31f3db 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -64,7 +64,7 @@ module NavHelper
end
def admin_analytics_nav_links
- %w(dev_ops_report)
+ %w(dev_ops_report usage_trends)
end
def group_issues_sub_menu_items
@@ -73,9 +73,7 @@ module NavHelper
milestones#index
boards#index
boards#show
- ].tap do |paths|
- paths << 'labels#index' if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml)
- end
+ ]
end
private
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index fb410c46128..5d2f225edcf 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -2,10 +2,11 @@
module OperationsHelper
include Gitlab::Utils::StrongMemoize
+ include IntegrationsHelper
- def prometheus_service
- strong_memoize(:prometheus_service) do
- @project.find_or_initialize_service(::PrometheusService.to_param)
+ def prometheus_integration
+ strong_memoize(:prometheus_integration) do
+ @project.find_or_initialize_integration(::Integrations::Prometheus.to_param)
end
end
@@ -14,11 +15,11 @@ module OperationsHelper
templates = setting.available_issue_templates.map { |t| { key: t.key, name: t.name } }
{
- 'prometheus_activated' => prometheus_service.manual_configuration?.to_s,
- 'prometheus_form_path' => scoped_integration_path(prometheus_service),
+ 'prometheus_activated' => prometheus_integration.manual_configuration?.to_s,
+ 'prometheus_form_path' => scoped_integration_path(prometheus_integration),
'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project),
'prometheus_authorization_key' => @project.alerting_setting&.token,
- 'prometheus_api_url' => prometheus_service.api_url,
+ 'prometheus_api_url' => prometheus_integration.api_url,
'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json),
'alerts_setup_url' => help_page_path('operations/incident_management/integrations.md', anchor: 'configuration'),
'alerts_usage_url' => project_alert_management_index_path(@project),
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index fe41c041b4f..50984415aa5 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -57,10 +57,35 @@ module PackagesHelper
def show_cleanup_policy_on_alert(project)
Gitlab.com? &&
Gitlab.config.registry.enabled &&
- project.container_registry_enabled &&
+ project.feature_available?(:container_registry, current_user) &&
!Gitlab::CurrentSettings.container_expiration_policies_enable_historic_entries &&
Feature.enabled?(:container_expiration_policies_historic_entry, project) &&
project.container_expiration_policy.nil? &&
project.container_repositories.exists?
end
+
+ def package_details_data(project, package = nil)
+ {
+ package: package ? package_from_presenter(package) : nil,
+ can_delete: can?(current_user, :destroy_package, project).to_s,
+ svg_path: image_path('illustrations/no-packages.svg'),
+ npm_path: package_registry_instance_url(:npm),
+ npm_help_path: help_page_path('user/packages/npm_registry/index'),
+ maven_path: package_registry_project_url(project.id, :maven),
+ maven_help_path: help_page_path('user/packages/maven_repository/index'),
+ conan_path: package_registry_project_url(project.id, :conan),
+ conan_help_path: help_page_path('user/packages/conan_repository/index'),
+ nuget_path: nuget_package_registry_url(project.id),
+ nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
+ pypi_path: pypi_registry_url(project.id),
+ pypi_setup_path: package_registry_project_url(project.id, :pypi),
+ pypi_help_path: help_page_path('user/packages/pypi_repository/index'),
+ composer_path: composer_registry_url(project&.group&.id),
+ composer_help_path: help_page_path('user/packages/composer_repository/index'),
+ project_name: project.name,
+ project_list_url: project_packages_path(project),
+ group_list_url: project.group ? group_packages_path(project.group) : '',
+ composer_config_repository_name: composer_config_repository_name(project.group&.id)
+ }
+ end
end
diff --git a/app/helpers/personal_access_tokens_helper.rb b/app/helpers/personal_access_tokens_helper.rb
new file mode 100644
index 00000000000..5cc8d21096f
--- /dev/null
+++ b/app/helpers/personal_access_tokens_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokensHelper
+ def personal_access_token_expiration_enforced?
+ false
+ end
+end
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index b46e3eb3bc3..b50e287a509 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -29,13 +29,13 @@ module Projects::AlertManagementHelper
private
def has_managed_prometheus?(project)
- project.prometheus_service&.prometheus_available? == true
+ project.prometheus_integration&.prometheus_available? == true
end
def alert_management_enabled?(project)
!!(
project.alert_management_alerts.any? ||
- project.prometheus_service_active? ||
+ project.prometheus_integration_active? ||
AlertManagement::HttpIntegrationsFinder.new(project, active: true).execute.any?
)
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 8800bd0643c..752e91df9c4 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -350,6 +350,10 @@ module ProjectsHelper
nil
end
+ def show_terraform_banner?(project)
+ project.repository_languages.with_programming_language('HCL').exists? && project.terraform_states.empty?
+ end
+
private
def tab_ability_map
@@ -530,7 +534,8 @@ module ProjectsHelper
pagesAvailable: Gitlab.config.pages.enabled,
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?,
- pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control')
+ pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control'),
+ issuesHelpPath: help_page_path('user/project/issues/index')
}
end
@@ -611,21 +616,6 @@ module ProjectsHelper
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
- def settings_container_registry_expiration_policy_available?(project)
- Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) &&
- can_destroy_container_registry_image?(current_user, project)
- end
-
- def settings_packages_and_registries_enabled?(project)
- Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) &&
- can_destroy_container_registry_image?(current_user, project)
- end
-
- def can_destroy_container_registry_image?(current_user, project)
- Gitlab.config.registry.enabled &&
- can?(current_user, :destroy_container_image, project)
- end
-
def build_project_breadcrumb_link(project)
project_name = simple_sanitize(project.name)
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
index 24131e32c6c..91adc36749b 100644
--- a/app/helpers/registrations_helper.rb
+++ b/app/helpers/registrations_helper.rb
@@ -1,13 +1,6 @@
# frozen_string_literal: true
module RegistrationsHelper
- def social_signin_enabled?
- ::Gitlab.dev_env_or_com? &&
- omniauth_enabled? &&
- devise_mapping.omniauthable? &&
- button_based_providers_enabled?
- end
-
def signup_username_data_attributes
{
min_length: User::MIN_USERNAME_LENGTH,
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index de9288121c4..4fa61191ba5 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -4,6 +4,10 @@ module ReleasesHelper
IMAGE_PATH = 'illustrations/releases.svg'
DOCUMENTATION_PATH = 'user/project/releases/index'
+ # This needs to be kept in sync with the constant in
+ # app/assets/javascripts/releases/constants.js
+ DEFAULT_SORT = 'RELEASED_AT_DESC'
+
def illustration
image_path(IMAGE_PATH)
end
@@ -20,15 +24,24 @@ module ReleasesHelper
documentation_path: help_page
}.tap do |data|
if can?(current_user, :create_release, @project)
- data[:new_release_path] = if Feature.enabled?(:new_release_page, @project, default_enabled: true)
- new_project_release_path(@project)
- else
- new_project_tag_path(@project)
- end
+ data[:new_release_path] = new_project_release_path(@project)
end
end
end
+ # For simplicity, only optimize non-paginated requests
+ def use_startup_query_for_index_page?
+ params[:before].nil? && params[:after].nil?
+ end
+
+ def index_page_startup_query_variables
+ {
+ fullPath: @project.full_path,
+ sort: DEFAULT_SORT,
+ first: 1
+ }
+ end
+
def data_for_show_page
{
project_id: @project.id,
diff --git a/app/helpers/routing/artifacts_helper.rb b/app/helpers/routing/artifacts_helper.rb
new file mode 100644
index 00000000000..32df9098e48
--- /dev/null
+++ b/app/helpers/routing/artifacts_helper.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Routing
+ module ArtifactsHelper
+ # Rails path generators are slow because they need to do large regex comparisons
+ # against the arguments. We can speed this up 10x by generating the strings directly.
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format)
+ def fast_download_project_job_artifacts_path(project, job, params = {})
+ expose_fast_artifacts_path(project, job, :download, params)
+ end
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format)
+ def fast_keep_project_job_artifacts_path(project, job)
+ expose_fast_artifacts_path(project, job, :keep)
+ end
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/browse(/*path)
+ def fast_browse_project_job_artifacts_path(project, job)
+ expose_fast_artifacts_path(project, job, :browse)
+ end
+
+ def expose_fast_artifacts_path(project, job, action, params = {})
+ path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}"
+
+ unless params.empty?
+ path += "?#{params.to_query}"
+ end
+
+ Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path)
+ end
+
+ def artifacts_action_path(path, project, build)
+ action, path_params = path.split('/', 2)
+ args = [project, build, path_params]
+
+ case action
+ when 'download'
+ download_project_job_artifacts_path(*args)
+ when 'browse'
+ browse_project_job_artifacts_path(*args)
+ when 'file'
+ file_project_job_artifacts_path(*args)
+ when 'raw'
+ raw_project_job_artifacts_path(*args)
+ end
+ end
+ end
+end
diff --git a/app/helpers/routing/graphql_helper.rb b/app/helpers/routing/graphql_helper.rb
new file mode 100644
index 00000000000..beefbb9b387
--- /dev/null
+++ b/app/helpers/routing/graphql_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Routing
+ module GraphqlHelper
+ def graphql_etag_pipeline_path(pipeline)
+ [api_graphql_path, "pipelines/id/#{pipeline.id}"].join(':')
+ end
+
+ def graphql_etag_pipeline_sha_path(sha)
+ [api_graphql_path, "pipelines/sha/#{sha}"].join(':')
+ end
+ end
+end
diff --git a/app/helpers/routing/groups/members_helper.rb b/app/helpers/routing/groups/members_helper.rb
new file mode 100644
index 00000000000..eabeacff1d7
--- /dev/null
+++ b/app/helpers/routing/groups/members_helper.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Routing
+ module Groups
+ module MembersHelper
+ def group_members_url(group, *args)
+ group_group_members_url(group, *args)
+ end
+
+ def group_member_path(group_member, *args)
+ group_group_member_path(group_member.source, group_member)
+ end
+
+ def request_access_group_members_path(group, *args)
+ request_access_group_group_members_path(group)
+ end
+
+ def leave_group_members_path(group, *args)
+ leave_group_group_members_path(group)
+ end
+
+ def approve_access_request_group_member_path(group_member, *args)
+ approve_access_request_group_group_member_path(group_member.source, group_member)
+ end
+
+ def resend_invite_group_member_path(group_member, *args)
+ resend_invite_group_group_member_path(group_member.source, group_member)
+ end
+ end
+ end
+end
diff --git a/app/helpers/routing/members_helper.rb b/app/helpers/routing/members_helper.rb
new file mode 100644
index 00000000000..18f6d06ab3b
--- /dev/null
+++ b/app/helpers/routing/members_helper.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Routing
+ module MembersHelper
+ def source_members_url(member)
+ case member.source_type
+ when 'Namespace'
+ group_group_members_url(member.source)
+ when 'Project'
+ project_project_members_url(member.source)
+ end
+ end
+ end
+end
diff --git a/app/helpers/routing/pipeline_schedules_helper.rb b/app/helpers/routing/pipeline_schedules_helper.rb
new file mode 100644
index 00000000000..6501018a365
--- /dev/null
+++ b/app/helpers/routing/pipeline_schedules_helper.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Routing
+ module PipelineSchedulesHelper
+ def pipeline_schedules_path(project, *args)
+ project_pipeline_schedules_path(project, *args)
+ end
+
+ def pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ project_pipeline_schedule_path(project, schedule, *args)
+ end
+
+ def edit_pipeline_schedule_path(schedule)
+ project = schedule.project
+ edit_project_pipeline_schedule_path(project, schedule)
+ end
+
+ def play_pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ play_project_pipeline_schedule_path(project, schedule, *args)
+ end
+
+ def take_ownership_pipeline_schedule_path(schedule, *args)
+ project = schedule.project
+ take_ownership_project_pipeline_schedule_path(project, schedule, *args)
+ end
+ end
+end
diff --git a/app/helpers/routing/projects/members_helper.rb b/app/helpers/routing/projects/members_helper.rb
new file mode 100644
index 00000000000..72f88a1408b
--- /dev/null
+++ b/app/helpers/routing/projects/members_helper.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Routing
+ module Projects
+ module MembersHelper
+ def project_members_url(project, *args)
+ project_project_members_url(project, *args)
+ end
+
+ def project_member_path(project_member, *args)
+ project_project_member_path(project_member.source, project_member)
+ end
+
+ def request_access_project_members_path(project, *args)
+ request_access_project_project_members_path(project)
+ end
+
+ def leave_project_members_path(project, *args)
+ leave_project_project_members_path(project)
+ end
+
+ def approve_access_request_project_member_path(project_member, *args)
+ approve_access_request_project_project_member_path(project_member.source, project_member)
+ end
+
+ def resend_invite_project_member_path(project_member, *args)
+ resend_invite_project_project_member_path(project_member.source, project_member)
+ end
+ end
+ end
+end
diff --git a/app/helpers/routing/projects_helper.rb b/app/helpers/routing/projects_helper.rb
new file mode 100644
index 00000000000..fb000b29739
--- /dev/null
+++ b/app/helpers/routing/projects_helper.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Routing
+ module ProjectsHelper
+ def project_tree_path(project, ref = nil, *args)
+ namespace_project_tree_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
+ end
+
+ def project_commits_path(project, ref = nil, *args)
+ namespace_project_commits_path(project.namespace, project, ref || @ref || project.repository.root_ref, *args) # rubocop:disable Cop/ProjectPathHelper
+ end
+
+ def project_ref_path(project, ref_name, *args)
+ project_commits_path(project, ref_name, *args)
+ end
+
+ def environment_path(environment, *args)
+ project_environment_path(environment.project, environment, *args)
+ end
+
+ def environment_metrics_path(environment, *args)
+ metrics_project_environment_path(environment.project, environment, *args)
+ end
+
+ def environment_delete_path(environment, *args)
+ expose_path(api_v4_projects_environments_path(id: environment.project.id, environment_id: environment.id))
+ end
+
+ def issue_path(entity, *args)
+ project_issue_path(entity.project, entity, *args)
+ end
+
+ def merge_request_path(entity, *args)
+ project_merge_request_path(entity.project, entity, *args)
+ end
+
+ def pipeline_path(pipeline, *args)
+ project_pipeline_path(pipeline.project, pipeline.id, *args)
+ end
+
+ def issue_url(entity, *args)
+ project_issue_url(entity.project, entity, *args)
+ end
+
+ def merge_request_url(entity, *args)
+ project_merge_request_url(entity.project, entity, *args)
+ end
+
+ def pipeline_url(pipeline, *args)
+ project_pipeline_url(pipeline.project, pipeline.id, *args)
+ end
+
+ def pipeline_job_url(pipeline, build, *args)
+ project_job_url(pipeline.project, build.id, *args)
+ end
+
+ def commits_url(entity, *args)
+ project_commits_url(entity.project, entity.source_ref, *args)
+ end
+
+ def commit_url(entity, *args)
+ project_commit_url(entity.project, entity.sha, *args)
+ end
+
+ def release_url(entity, *args)
+ project_release_url(entity.project, entity, *args)
+ end
+
+ def edit_milestone_path(entity, *args)
+ if entity.resource_parent.is_a?(Group)
+ edit_group_milestone_path(entity.resource_parent, entity, *args)
+ else
+ edit_project_milestone_path(entity.resource_parent, entity, *args)
+ end
+ end
+
+ def toggle_subscription_path(entity, *args)
+ if entity.is_a?(Issue)
+ toggle_subscription_project_issue_path(entity.project, entity)
+ else
+ toggle_subscription_project_merge_request_path(entity.project, entity)
+ end
+ end
+ end
+end
diff --git a/app/helpers/routing/snippets_helper.rb b/app/helpers/routing/snippets_helper.rb
new file mode 100644
index 00000000000..19450c1d033
--- /dev/null
+++ b/app/helpers/routing/snippets_helper.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module Routing
+ module SnippetsHelper
+ def gitlab_snippet_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_path(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ snippet_path(snippet, *new_args)
+ end
+ end
+
+ def gitlab_snippet_url(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_url(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ snippet_url(snippet, *new_args)
+ end
+ end
+
+ def gitlab_dashboard_snippets_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippets_path(snippet.project, *args)
+ else
+ dashboard_snippets_path
+ end
+ end
+
+ def gitlab_raw_snippet_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ raw_project_snippet_path(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ raw_snippet_path(snippet, *new_args)
+ end
+ end
+
+ def gitlab_raw_snippet_url(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ raw_project_snippet_url(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ raw_snippet_url(snippet, *new_args)
+ end
+ end
+
+ def gitlab_raw_snippet_blob_url(snippet, path, ref = nil, **options)
+ params = {
+ snippet_id: snippet,
+ ref: ref || snippet.default_branch,
+ path: path
+ }
+
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_blob_raw_url(snippet.project, **params, **options)
+ else
+ snippet_blob_raw_url(**params, **options)
+ end
+ end
+
+ def gitlab_raw_snippet_blob_path(snippet, path, ref = nil, **options)
+ gitlab_raw_snippet_blob_url(snippet, path, ref, only_path: true, **options)
+ end
+
+ def gitlab_snippet_notes_path(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_notes_path(snippet, *new_args)
+ end
+
+ def gitlab_snippet_notes_url(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_notes_url(snippet, *new_args)
+ end
+
+ def gitlab_snippet_note_path(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_note_path(snippet, note, *new_args)
+ end
+
+ def gitlab_snippet_note_url(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_note_url(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_note_path(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_note_path(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_note_url(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_note_url(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_path(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_path(snippet, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_url(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_url(snippet, *new_args)
+ end
+
+ def preview_markdown_path(parent, *args)
+ return group_preview_markdown_path(parent, *args) if parent.is_a?(Group)
+
+ if @snippet.is_a?(PersonalSnippet)
+ preview_markdown_snippets_path
+ else
+ preview_markdown_project_path(parent, *args)
+ end
+ end
+
+ def toggle_award_emoji_personal_snippet_path(*args)
+ toggle_award_emoji_snippet_path(*args)
+ end
+
+ def toggle_award_emoji_project_project_snippet_path(*args)
+ toggle_award_emoji_project_snippet_path(*args)
+ end
+
+ def toggle_award_emoji_project_project_snippet_url(*args)
+ toggle_award_emoji_project_snippet_url(*args)
+ end
+
+ private
+
+ def snippet_query_params(snippet, *args)
+ opts = case args.last
+ when Hash
+ args.pop
+ when ActionController::Parameters
+ args.pop.to_h
+ else
+ {}
+ end
+
+ args << opts
+ end
+ end
+end
diff --git a/app/helpers/routing/wiki_helper.rb b/app/helpers/routing/wiki_helper.rb
new file mode 100644
index 00000000000..95f9e87de36
--- /dev/null
+++ b/app/helpers/routing/wiki_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Routing
+ module WikiHelper
+ def wiki_path(wiki, **options)
+ Gitlab::UrlBuilder.wiki_url(wiki, only_path: true, **options)
+ end
+
+ def wiki_page_path(wiki, page, **options)
+ Gitlab::UrlBuilder.wiki_page_url(wiki, page, only_path: true, **options)
+ end
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index e07ee22339a..ec8ed3d6e7f 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -131,7 +131,7 @@ module SearchHelper
end
def search_sort_options
- [
+ options = [
{
title: _('Created date'),
sortable: true,
@@ -149,6 +149,19 @@ module SearchHelper
}
}
]
+
+ if search_service.scope == 'issues' && Feature.enabled?(:search_sort_issues_by_popularity)
+ options << {
+ title: _('Popularity'),
+ sortable: true,
+ sortParam: {
+ asc: 'popularity_asc',
+ desc: 'popularity_desc'
+ }
+ }
+ end
+
+ options
end
private
@@ -172,12 +185,12 @@ module SearchHelper
# Autocomplete results for internal help pages
def help_autocomplete
[
- { category: "Help", label: _("API Help"), url: help_page_path("api/README") },
+ { category: "Help", label: _("API Help"), url: help_page_path("api/index") },
{ category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
{ category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
{ category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
{ category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
- { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/index") },
{ category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
{ category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }
]
@@ -301,7 +314,7 @@ module SearchHelper
if @scope == scope
li_class = 'active'
- count = @search_results.formatted_count(scope)
+ count = @timeout ? 0 : @search_results.formatted_count(scope)
else
badge_class = 'js-search-count hidden'
badge_data = { url: search_count_path(search_params) }
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index ef737b25bc7..117f662fec6 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -1,6 +1,15 @@
# frozen_string_literal: true
module SessionsHelper
+ include Gitlab::Utils::StrongMemoize
+
+ def recently_confirmed_com?
+ strong_memoize(:recently_confirmed_com) do
+ ::Gitlab.dev_env_or_com? &&
+ !!flash[:notice]&.include?(t(:confirmed, scope: [:devise, :confirmations]))
+ end
+ end
+
def unconfirmed_email?
flash[:alert] == t(:unconfirmed, scope: [:devise, :failure])
end
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 39ad8ed8a0f..77af6e37099 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -2,34 +2,75 @@
module SidebarsHelper
def sidebar_tracking_attributes_by_object(object)
+ sidebar_attributes_for_object(object).fetch(:tracking_attrs, {})
+ end
+
+ def sidebar_qa_selector(object)
+ sidebar_attributes_for_object(object).fetch(:sidebar_qa_selector, nil)
+ end
+
+ def scope_qa_menu_item(object)
+ sidebar_attributes_for_object(object).fetch(:scope_qa_menu_item, nil)
+ end
+
+ def scope_avatar_classes(object)
+ %w[avatar-container rect-avatar s32].tap do |klasses|
+ klass = sidebar_attributes_for_object(object).fetch(:scope_avatar_class, nil)
+ klasses << klass if klass
+ end
+ end
+
+ def project_sidebar_context(project, user, current_ref)
+ context_data = project_sidebar_context_data(project, user, current_ref)
+
+ Sidebars::Projects::Context.new(**context_data)
+ end
+
+ def group_sidebar_context(group, user)
+ context_data = group_sidebar_context_data(group, user)
+
+ Sidebars::Groups::Context.new(**context_data)
+ end
+
+ private
+
+ def sidebar_attributes_for_object(object)
case object
when Project
- sidebar_project_tracking_attrs
+ sidebar_project_attributes
when Group
- sidebar_group_tracking_attrs
+ sidebar_group_attributes
when User
- sidebar_user_profile_tracking_attrs
+ sidebar_user_attributes
else
{}
end
end
- def project_sidebar_context(project, user, current_ref)
- context_data = project_sidebar_context_data(project, user, current_ref)
-
- Sidebars::Projects::Context.new(**context_data)
+ def sidebar_project_attributes
+ {
+ tracking_attrs: sidebar_project_tracking_attrs,
+ sidebar_qa_selector: 'project_sidebar',
+ scope_qa_menu_item: 'Project scope',
+ scope_avatar_class: 'project_avatar'
+ }
end
- def sidebar_refactor_enabled?
- Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml)
+ def sidebar_group_attributes
+ {
+ tracking_attrs: sidebar_group_tracking_attrs,
+ sidebar_qa_selector: 'group_sidebar',
+ scope_qa_menu_item: 'Group scope',
+ scope_avatar_class: 'group_avatar'
+ }
end
- def sidebar_refactor_disabled?
- !sidebar_refactor_enabled?
+ def sidebar_user_attributes
+ {
+ tracking_attrs: sidebar_user_profile_tracking_attrs
+ }
end
- private
-
def sidebar_project_tracking_attrs
tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation')
end
@@ -54,6 +95,13 @@ module SidebarsHelper
show_cluster_hint: show_gke_cluster_integration_callout?(project)
}
end
+
+ def group_sidebar_context_data(group, user)
+ {
+ current_user: user,
+ container: group
+ }
+ end
end
SidebarsHelper.prepend_mod_with('SidebarsHelper')
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 0bb9e9e9bdd..da32dfb0b9b 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -26,6 +26,9 @@ module SortingHelper
sort_value_recently_updated => sort_title_recently_updated,
sort_value_popularity => sort_title_popularity,
sort_value_priority => sort_title_priority,
+ sort_value_merged_date => sort_title_merged_date,
+ sort_value_merged_recently => sort_title_merged_recently,
+ sort_value_merged_earlier => sort_title_merged_earlier,
sort_value_upvotes => sort_title_upvotes,
sort_value_contacted_date => sort_title_contacted_date,
sort_value_relative_position => sort_title_relative_position,
@@ -168,19 +171,6 @@ module SortingHelper
}
end
- def member_sort_options_hash
- {
- sort_value_access_level_asc => sort_title_access_level_asc,
- sort_value_access_level_desc => sort_title_access_level_desc,
- sort_value_last_joined => sort_title_last_joined,
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_oldest_joined => sort_title_oldest_joined,
- sort_value_oldest_signin => sort_title_oldest_signin,
- sort_value_recently_signin => sort_title_recently_signin
- }
- end
-
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
@@ -191,6 +181,7 @@ module SortingHelper
sort_value_oldest_updated => sort_value_recently_updated,
sort_value_milestone_later => sort_value_milestone,
sort_value_due_date_later => sort_value_due_date,
+ sort_value_merged_recently => sort_value_merged_date,
sort_value_least_popular => sort_value_popularity
}
end
@@ -203,6 +194,8 @@ module SortingHelper
sort_value_milestone => sort_value_milestone_later,
sort_value_due_date => sort_value_due_date_later,
sort_value_due_date_soon => sort_value_due_date_later,
+ sort_value_merged_date => sort_value_merged_recently,
+ sort_value_merged_earlier => sort_value_merged_recently,
sort_value_popularity => sort_value_least_popular,
sort_value_most_popular => sort_value_least_popular
}.merge(issuable_sort_option_overrides)
@@ -223,7 +216,7 @@ module SortingHelper
def sort_direction_icon(sort_value)
case sort_value
- when sort_value_milestone, sort_value_due_date, /_asc\z/
+ when sort_value_milestone, sort_value_due_date, sort_value_merged_date, /_asc\z/
'sort-lowest'
else
'sort-highest'
diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb
index 28d70f1db45..9b839f4e9bc 100644
--- a/app/helpers/sorting_titles_values_helper.rb
+++ b/app/helpers/sorting_titles_values_helper.rb
@@ -2,14 +2,6 @@
module SortingTitlesValuesHelper
# Titles.
- def sort_title_access_level_asc
- s_('SortOptions|Access level, ascending')
- end
-
- def sort_title_access_level_desc
- s_('SortOptions|Access level, descending')
- end
-
def sort_title_created_date
s_('SortOptions|Created date')
end
@@ -34,6 +26,18 @@ module SortingTitlesValuesHelper
s_('SortOptions|Label priority')
end
+ def sort_title_merged_date
+ s_('SortOptions|Merged date')
+ end
+
+ def sort_title_merged_recently
+ s_('SortOptions|Merged recently')
+ end
+
+ def sort_title_merged_earlier
+ s_('SortOptions|Merged earlier')
+ end
+
def sort_title_largest_group
s_('SortOptions|Largest group')
end
@@ -42,10 +46,6 @@ module SortingTitlesValuesHelper
s_('SortOptions|Largest repository')
end
- def sort_title_last_joined
- s_('SortOptions|Last joined')
- end
-
def sort_title_latest_activity
s_('SortOptions|Last updated')
end
@@ -82,10 +82,6 @@ module SortingTitlesValuesHelper
s_('SortOptions|Oldest created')
end
- def sort_title_oldest_joined
- s_('SortOptions|Oldest joined')
- end
-
def sort_title_oldest_signin
s_('SortOptions|Oldest sign in')
end
@@ -167,14 +163,6 @@ module SortingTitlesValuesHelper
end
# Values.
- def sort_value_access_level_asc
- 'access_level_asc'
- end
-
- def sort_value_access_level_desc
- 'access_level_desc'
- end
-
def sort_value_created_date
'created_date'
end
@@ -199,6 +187,18 @@ module SortingTitlesValuesHelper
'label_priority'
end
+ def sort_value_merged_date
+ 'merged_at'
+ end
+
+ def sort_value_merged_recently
+ 'merged_at_desc'
+ end
+
+ def sort_value_merged_earlier
+ 'merged_at_asc'
+ end
+
def sort_value_largest_group
'storage_size_desc'
end
@@ -207,10 +207,6 @@ module SortingTitlesValuesHelper
'storage_size_desc'
end
- def sort_value_last_joined
- 'last_joined'
- end
-
def sort_value_latest_activity
'latest_activity_desc'
end
@@ -247,10 +243,6 @@ module SortingTitlesValuesHelper
'oldest_sign_in'
end
- def sort_value_oldest_joined
- 'oldest_joined'
- end
-
def sort_value_oldest_updated
'updated_asc'
end
diff --git a/app/helpers/tracking_helper.rb b/app/helpers/tracking_helper.rb
index 7957038c21e..3f53bd535b2 100644
--- a/app/helpers/tracking_helper.rb
+++ b/app/helpers/tracking_helper.rb
@@ -17,6 +17,6 @@ module TrackingHelper
def tracking_enabled?
Rails.env.production? &&
- ::Gitlab::CurrentSettings.snowplow_enabled?
+ ::Gitlab::Tracking.enabled?
end
end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index c44da915105..4e6af298fcd 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -43,7 +43,7 @@ module UserCalloutsHelper
end
def show_customize_homepage_banner?
- !user_dismissed?(CUSTOMIZE_HOMEPAGE)
+ current_user.default_dashboard? && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
end
def show_feature_flags_new_version?
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index c1d05c2d3cf..93a0166f43e 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -123,130 +123,38 @@ module UsersHelper
!user.confirmed?
end
- def user_block_data(user, message)
- {
- path: block_admin_user_path(user),
- method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Block user %{username}?') % { username: sanitize_name(user.name) },
- messageHtml: message,
- okVariant: 'warning',
- okTitle: s_('AdminUsers|Block')
- }.to_json
- }
- end
-
- def user_unblock_data(user)
- {
- path: unblock_admin_user_path(user),
- method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Unblock user %{username}?') % { username: sanitize_name(user.name) },
- message: s_('AdminUsers|You can always block their account again if needed.'),
- okVariant: 'info',
- okTitle: s_('AdminUsers|Unblock')
- }.to_json
- }
- end
-
- def user_block_effects
- header = tag.p s_('AdminUsers|Blocking user has the following effects:')
-
- list = tag.ul do
- concat tag.li s_('AdminUsers|User will not be able to login')
- concat tag.li s_('AdminUsers|User will not be able to access git repositories')
- concat tag.li s_('AdminUsers|Personal projects will be left')
- concat tag.li s_('AdminUsers|Owned groups will be left')
- end
-
- header + list
- end
-
- def user_ban_data(user)
- {
- path: ban_admin_user_path(user),
- method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Ban user %{username}?') % { username: sanitize_name(user.name) },
- message: s_('AdminUsers|You can unban their account in the future. Their data remains intact.'),
- okVariant: 'warning',
- okTitle: s_('AdminUsers|Ban')
- }.to_json
- }
- end
-
- def user_unban_data(user)
- {
- path: unban_admin_user_path(user),
- method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Unban %{username}?') % { username: sanitize_name(user.name) },
- message: s_('AdminUsers|You ban their account in the future if necessary.'),
- okVariant: 'info',
- okTitle: s_('AdminUsers|Unban')
- }.to_json
- }
- end
-
- def user_ban_effects
- header = tag.p s_('AdminUsers|Banning the user has the following effects:')
-
- list = tag.ul do
- concat tag.li s_('AdminUsers|User will be blocked')
- end
-
- link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path("user/admin_area/moderate_users", anchor: "ban-a-user") }
- info = tag.p s_('AdminUsers|Learn more about %{link_start}banned users.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-
- header + list + info
- end
-
def ban_feature_available?
Feature.enabled?(:ban_user_feature_flag)
end
- def user_deactivation_data(user, message)
- {
- path: deactivate_admin_user_path(user),
- method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Deactivate user %{username}?') % { username: sanitize_name(user.name) },
- messageHtml: message,
- okVariant: 'warning',
- okTitle: s_('AdminUsers|Deactivate')
- }.to_json
- }
- end
+ def confirm_user_data(user)
+ message = if user.unconfirmed_email.present?
+ _('This user has an unconfirmed email address (%{email}). You may force a confirmation.') % { email: user.unconfirmed_email }
+ else
+ _('This user has an unconfirmed email address. You may force a confirmation.')
+ end
+
+ modal_attributes = Gitlab::Json.dump({
+ title: s_('AdminUsers|Confirm user %{username}?') % { username: sanitize_name(user.name) },
+ messageHtml: message,
+ actionPrimary: {
+ text: s_('AdminUsers|Confirm user'),
+ attributes: [{ variant: 'info', 'data-qa-selector': 'confirm_user_confirm_button' }]
+ },
+ actionSecondary: {
+ text: _('Cancel'),
+ attributes: [{ variant: 'default' }]
+ }
+ })
- def user_activation_data(user)
{
- path: activate_admin_user_path(user),
+ path: confirm_admin_user_path(user),
method: 'put',
- modal_attributes: {
- title: s_('AdminUsers|Activate user %{username}?') % { username: sanitize_name(user.name) },
- message: s_('AdminUsers|You can always deactivate their account again if needed.'),
- okVariant: 'info',
- okTitle: s_('AdminUsers|Activate')
- }.to_json
+ modal_attributes: modal_attributes,
+ qa_selector: 'confirm_user_button'
}
end
- def user_deactivation_effects
- header = tag.p s_('AdminUsers|Deactivating a user has the following effects:')
-
- list = tag.ul do
- concat tag.li s_('AdminUsers|The user will be logged out')
- concat tag.li s_('AdminUsers|The user will not be able to access git repositories')
- concat tag.li s_('AdminUsers|The user will not be able to access the API')
- concat tag.li s_('AdminUsers|The user will not receive any notifications')
- concat tag.li s_('AdminUsers|The user will not be able to use slash commands')
- concat tag.li s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account')
- concat tag.li s_('AdminUsers|Personal projects, group and user history will be left intact')
- end
-
- header + list
- end
-
def user_display_name(user)
return s_('UserProfile|Blocked user') if user.blocked?
@@ -256,6 +164,13 @@ module UsersHelper
user.name
end
+ def admin_user_actions_data_attributes(user)
+ {
+ user: Admin::UserEntity.represent(user, { current_user: current_user }).to_json,
+ paths: admin_users_paths.to_json
+ }
+ end
+
private
def admin_users_paths
@@ -270,7 +185,9 @@ module UsersHelper
unlock: unlock_admin_user_path(:id),
delete: admin_user_path(:id),
delete_with_contributions: admin_user_path(:id),
- admin_user: admin_user_path(:id)
+ admin_user: admin_user_path(:id),
+ ban: ban_admin_user_path(:id),
+ unban: unban_admin_user_path(:id)
}
end
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index 5fca00c5dce..ccccfcb930b 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -32,11 +32,11 @@ module WhatsNewHelper
def whats_new_variants_description(variant)
case variant
when 'all_tiers'
- _("What's new presents new features from all tiers to help you keep track of all new features.")
+ _("Include new features from all tiers.")
when 'current_tier'
- _("What's new presents new features for your current subscription tier, while hiding new features not available to your subscription tier.")
+ _("Only include features new to your current subscription tier.")
when 'disabled'
- _("What's new is disabled and can no longer be viewed.")
+ _("%{italic_start}What's new%{italic_end} is inactive and cannot be viewed.").html_safe % { italic_start: '<i>'.html_safe, italic_end: '</i>'.html_safe }
end
end
end
diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb
new file mode 100644
index 00000000000..f4540ef81a5
--- /dev/null
+++ b/app/mailers/emails/admin_notification.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Emails
+ module AdminNotification
+ def send_admin_notification(user_id, subject, body)
+ user = User.find(user_id)
+ email = user.notification_email
+ @unsubscribe_url = unsubscribe_url(email: Base64.urlsafe_encode64(email))
+ @body = body
+ mail to: email, subject: subject
+ end
+
+ def send_unsubscribed_notification(user_id)
+ user = User.find(user_id)
+ email = user.notification_email
+ mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
+ end
+ end
+end
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 2efcba54c13..a8affb34f62 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -26,6 +26,14 @@ module Emails
subject: subject(_("GitLab account request rejected")))
end
+ def user_deactivated_email(name, email)
+ @name = name
+
+ profile_email_with_layout(
+ to: email,
+ subject: subject(_('Your account has been deactivated')))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def new_ssh_key_email(key_id)
@key = Key.find_by(id: key_id)
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index e8034ef9b57..66eb2c646a9 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -20,9 +20,7 @@ module Emails
options = service_desk_options(email_sender, 'thank_you', @issue.external_author)
.merge(subject: "Re: #{subject_base}")
- mail_new_thread(@issue, options).tap do
- Gitlab::Metrics::BackgroundTransaction.current&.add_event(:service_desk_thank_you_email)
- end
+ mail_new_thread(@issue, options)
end
def service_desk_new_note_email(issue_id, note_id, recipient)
@@ -33,9 +31,7 @@ module Emails
options = service_desk_options(email_sender, 'new_note', recipient)
.merge(subject: subject_base)
- mail_answer_thread(@issue, options).tap do
- Gitlab::Metrics::BackgroundTransaction.current&.add_event(:service_desk_new_note_email)
- end
+ mail_answer_thread(@issue, options)
end
private
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index dd75ab4bf03..03b70fffde1 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -22,6 +22,7 @@ class Notify < ApplicationMailer
include Emails::Reviews
include Emails::ServiceDesk
include Emails::InProductMarketing
+ include Emails::AdminNotification
helper TimeboxesHelper
helper MergeRequestsHelper
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 6a63a8d46ba..a185448d5ea 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -54,6 +54,14 @@ class Ability
end
end
+ def feature_flags_readable_by_user(feature_flags, user = nil, filters: {})
+ feature_flags = apply_filters_if_needed(feature_flags, user, filters)
+
+ DeclarativePolicy.user_scope do
+ feature_flags.select { |flag| allowed?(user, :read_feature_flag, flag) }
+ end
+ end
+
def allowed?(user, ability, subject = :global, opts = {})
if subject.is_a?(Hash)
opts = subject
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index 156111ffaf3..679406e68d7 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -210,7 +210,7 @@ module AlertManagement
end
def self.link_reference_pattern
- @link_reference_pattern ||= super("alert_management", /(?<alert>\d+)\/details(\#)?/)
+ @link_reference_pattern ||= super("alert_management", %r{(?<alert>\d+)/details(\#)?})
end
def self.reference_valid?(reference)
@@ -225,6 +225,10 @@ module AlertManagement
open_statuses.include?(status)
end
+ def open?
+ self.class.open_status?(status_name)
+ end
+
def status_event_for(status)
self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event
end
@@ -248,10 +252,10 @@ module AlertManagement
"#{project.to_reference_base(from, full: full)}#{reference}"
end
- def execute_services
- return unless project.has_active_services?(:alert_hooks)
+ def execute_integrations
+ return unless project.has_active_integrations?(:alert_hooks)
- project.execute_services(hook_data, :alert_hooks)
+ project.execute_integrations(hook_data, :alert_hooks)
end
# Representation of the alert's payload. Avoid accessing
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index 2caa9a18445..0c3b1679dc3 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -53,10 +53,6 @@ module AlertManagement
endpoint_identifier == LEGACY_IDENTIFIER
end
- def token_changed?
- attribute_changed?(:token)
- end
-
# Blank token assignment triggers token reset
def prevent_token_assignment
if token.present? && token_changed?
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index f8047ed9b78..a7140cc0718 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -172,6 +172,11 @@ class ApplicationSetting < ApplicationRecord
addressable_url: { enforce_sanitization: true },
if: :gitpod_enabled
+ validates :mailgun_signing_key,
+ presence: true,
+ length: { maximum: 255 },
+ if: :mailgun_events_enabled
+
validates :snowplow_collector_hostname,
presence: true,
hostname: true,
@@ -288,7 +293,7 @@ class ApplicationSetting < ApplicationRecord
validates :user_default_internal_regex, js_regex: true, allow_nil: true
validates :personal_access_token_prefix,
- format: { with: /\A[a-zA-Z0-9_+=\/@:.-]+\z/,
+ format: { with: %r{\A[a-zA-Z0-9_+=/@:.-]+\z},
message: _("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") },
length: { maximum: 20, message: _('is too long (maximum is %{count} characters)') },
allow_blank: true
@@ -372,6 +377,8 @@ class ApplicationSetting < ApplicationRecord
end
end
+ validate :check_valid_runner_registrars
+
validate :terms_exist, if: :enforce_terms?
validates :external_authorization_service_default_label,
@@ -550,6 +557,7 @@ class ApplicationSetting < ApplicationRecord
attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_32_aes_256_gcm
attr_encrypted :cloud_license_auth_token, encryption_options_base_32_aes_256_gcm
attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_32_aes_256_gcm
+ attr_encrypted :mailgun_signing_key, encryption_options_base_32_aes_256_gcm.merge(encode: false)
validates :disable_feed_token,
inclusion: { in: [true, false], message: _('must be a boolean value') }
@@ -582,7 +590,7 @@ class ApplicationSetting < ApplicationRecord
end
def sourcegraph_url_is_com?
- !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/)
+ !!(sourcegraph_url =~ %r{\Ahttps://(www\.)?sourcegraph\.com})
end
def instance_review_permitted?
diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb
index bab036f5697..acdd7e4155c 100644
--- a/app/models/application_setting/term.rb
+++ b/app/models/application_setting/term.rb
@@ -3,12 +3,14 @@
class ApplicationSetting
class Term < ApplicationRecord
include CacheMarkdownField
- has_many :term_agreements
+ include NullifyIfBlank
- validates :terms, presence: true
+ has_many :term_agreements
cache_markdown_field :terms
+ nullify_if_blank :terms
+
def self.latest
order(:id).last
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index b613e698471..d7a594af84c 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -15,6 +15,7 @@ module ApplicationSettingImplementation
# forbidden.
FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+ VALID_RUNNER_REGISTRAR_TYPES = %w(project group).freeze
DEFAULT_PROTECTED_PATHS = [
'/users/password',
@@ -103,6 +104,8 @@ module ApplicationSettingImplementation
issues_create_limit: 300,
local_markdown_version: 0,
login_recaptcha_protection_enabled: false,
+ mailgun_signing_key: nil,
+ mailgun_events_enabled: false,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
max_import_size: 0,
@@ -186,6 +189,7 @@ module ApplicationSettingImplementation
user_default_external: false,
user_default_internal_regex: nil,
user_show_add_ssh_key_message: true,
+ valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES,
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 250,
container_registry_expiration_policies_worker_capacity: 0,
@@ -373,6 +377,10 @@ module ApplicationSettingImplementation
Settings.gitlab.usage_ping_enabled
end
+ def usage_ping_features_enabled?
+ usage_ping_enabled? && usage_ping_features_enabled
+ end
+
def usage_ping_enabled
usage_ping_can_be_configured? && super
end
@@ -507,6 +515,17 @@ module ApplicationSettingImplementation
end
end
+ def check_valid_runner_registrars
+ valid = valid_runner_registrar_combinations.include?(valid_runner_registrars)
+ errors.add(:valid_runner_registrars, _("%{value} is not included in the list") % { value: valid_runner_registrars }) unless valid
+ end
+
+ def valid_runner_registrar_combinations
+ 0.upto(VALID_RUNNER_REGISTRAR_TYPES.size).flat_map do |n|
+ VALID_RUNNER_REGISTRAR_TYPES.permutation(n).to_a
+ end
+ end
+
def terms_exist
return unless enforce_terms?
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 11036b76fc1..f17fff742fe 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -99,7 +99,12 @@ class AuditEvent < ApplicationRecord
end
def parallel_persist
- PARALLEL_PERSISTENCE_COLUMNS.each { |col| self[col] = details[col] }
+ PARALLEL_PERSISTENCE_COLUMNS.each do |name|
+ original = self[name] || self.details[name]
+ next unless original
+
+ self[name] = self.details[name] = original
+ end
end
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 5ba6100f169..c8f6b9aaedb 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -24,8 +24,8 @@ class AwardEmoji < ApplicationRecord
scope :named, -> (names) { where(name: names) }
scope :awarded_by, -> (users) { where(user: users) }
- after_save :expire_etag_cache
- after_destroy :expire_etag_cache
+ after_save :expire_cache
+ after_destroy :expire_cache
class << self
def votes_for_collection(ids, type)
@@ -60,7 +60,9 @@ class AwardEmoji < ApplicationRecord
self.name == UPVOTE_NAME
end
- def expire_etag_cache
+ def expire_cache
+ awardable.try(:bump_updated_at)
awardable.try(:expire_etag_cache)
+ awardable.try(:update_upvotes_count) if upvote?
end
end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 2185233a1ac..5731d38abe4 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -27,6 +27,7 @@ class Blob < SimpleDelegator
# type. LFS pointers to `.stl` files are assumed to always be the binary kind,
# and use the `BinarySTL` viewer.
RICH_VIEWERS = [
+ BlobViewer::CSV,
BlobViewer::Markup,
BlobViewer::Notebook,
BlobViewer::SVG,
diff --git a/app/models/blob_viewer/csv.rb b/app/models/blob_viewer/csv.rb
new file mode 100644
index 00000000000..633e3bd63d8
--- /dev/null
+++ b/app/models/blob_viewer/csv.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class CSV < Base
+ include Rich
+ include ClientSide
+
+ self.binary = false
+ self.extensions = %w(csv)
+ self.partial_name = 'csv'
+ self.switcher_icon = 'table'
+ end
+end
diff --git a/app/models/blob_viewer/go_mod.rb b/app/models/blob_viewer/go_mod.rb
index ae57e2c0526..d4d117f899c 100644
--- a/app/models/blob_viewer/go_mod.rb
+++ b/app/models/blob_viewer/go_mod.rb
@@ -5,13 +5,13 @@ module BlobViewer
include ServerSide
include Gitlab::Utils::StrongMemoize
- MODULE_REGEX = /
+ MODULE_REGEX = %r{
\A (?# beginning of file)
module\s+ (?# module directive)
(?<name>.*?) (?# module name)
- \s*(?:\/\/.*)? (?# comment)
+ \s*(?://.*)? (?# comment)
(?:\n|\z) (?# newline or end of file)
- /x.freeze
+ }x.freeze
self.file_types = %i(go_mod go_sum)
diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb
index 37a8e01d0f1..6f002a6b224 100644
--- a/app/models/blob_viewer/markup.rb
+++ b/app/models/blob_viewer/markup.rb
@@ -14,9 +14,7 @@ module BlobViewer
{}.tap do |h|
h[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
- if Feature.enabled?(:cached_markdown_blob, blob.project, default_enabled: true)
- h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id]
- end
+ h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id]
end
end
end
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
index 5d646313423..dee55675304 100644
--- a/app/models/bulk_import.rb
+++ b/app/models/bulk_import.rb
@@ -4,6 +4,8 @@
# projects to a GitLab instance. It associates the import with the responsible
# user.
class BulkImport < ApplicationRecord
+ MINIMUM_GITLAB_MAJOR_VERSION = 14
+
belongs_to :user, optional: false
has_one :configuration, class_name: 'BulkImports::Configuration'
@@ -31,4 +33,8 @@ class BulkImport < ApplicationRecord
transition any => :failed
end
end
+
+ def self.all_human_statuses
+ state_machine.states.map(&:human_name)
+ end
end
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index bb543b39a79..24f86b44841 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -48,6 +48,8 @@ class BulkImports::Entity < ApplicationRecord
enum source_type: { group_entity: 0, project_entity: 1 }
+ scope :by_user_id, ->(user_id) { joins(:bulk_import).where(bulk_imports: { user_id: user_id }) }
+
state_machine :status, initial: :created do
state :created, value: 0
state :started, value: 1
@@ -68,6 +70,10 @@ class BulkImports::Entity < ApplicationRecord
end
end
+ def self.all_human_statuses
+ state_machine.states.map(&:human_name)
+ end
+
def encoded_source_full_path
ERB::Util.url_encode(source_full_path)
end
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index 98804d18f27..ff165830cf1 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -9,7 +9,7 @@ module BulkImports
@relation = relation
@entity = @pipeline_tracker.entity
@configuration = @entity.bulk_import.configuration
- @client = Clients::HTTP.new(uri: @configuration.url, token: @configuration.access_token)
+ @client = Clients::HTTP.new(url: @configuration.url, token: @configuration.access_token)
end
def started?
diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb
index 7396f9d3655..ddea7c3f64c 100644
--- a/app/models/bulk_imports/file_transfer/base_config.rb
+++ b/app/models/bulk_imports/file_transfer/base_config.rb
@@ -30,7 +30,7 @@ module BulkImports
end
def portable_relations
- import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s)
+ import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) - skipped_relations
end
private
@@ -66,6 +66,10 @@ module BulkImports
def base_export_path
raise NotImplementedError
end
+
+ def skipped_relations
+ []
+ end
end
end
end
diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb
index 1f845b387b8..2266cbb484f 100644
--- a/app/models/bulk_imports/file_transfer/group_config.rb
+++ b/app/models/bulk_imports/file_transfer/group_config.rb
@@ -10,6 +10,10 @@ module BulkImports
def import_export_yaml
::Gitlab::ImportExport.group_config_file
end
+
+ def skipped_relations
+ @skipped_relations ||= %w(members)
+ end
end
end
end
diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb
index e42b5bfce3d..8a57f51c1c5 100644
--- a/app/models/bulk_imports/file_transfer/project_config.rb
+++ b/app/models/bulk_imports/file_transfer/project_config.rb
@@ -10,6 +10,10 @@ module BulkImports
def import_export_yaml
::Gitlab::ImportExport.config_file
end
+
+ def skipped_relations
+ @skipped_relations ||= %w(project_members group_members)
+ end
end
end
end
diff --git a/app/models/ci/base_model.rb b/app/models/ci/base_model.rb
new file mode 100644
index 00000000000..8fb752ead1d
--- /dev/null
+++ b/app/models/ci/base_model.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ # TODO: https://gitlab.com/groups/gitlab-org/-/epics/6168
+ #
+ # Do not use this yet outside of `ci_instance_variables`.
+ # This class is part of a migration to move all CI classes to a new separate database.
+ # Initially we are only going to be moving the `Ci::InstanceVariable` model and it will be duplicated in the main and CI tables
+ # Do not extend this class in any other models.
+ class BaseModel < ::ApplicationRecord
+ self.abstract_class = true
+
+ if Gitlab::Database.has_config?(:ci)
+ connects_to database: { writing: :ci, reading: :ci }
+ end
+ end
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index fdfffd9b0cd..4328f3f7a4b 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -11,7 +11,6 @@ module Ci
include Importable
include Ci::HasRef
include IgnorableColumns
- include TaggableQueries
BuildArchivedError = Class.new(StandardError)
@@ -136,6 +135,7 @@ module Ci
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
scope :eager_load_job_artifacts_archive, -> { includes(:job_artifacts_archive) }
+ scope :eager_load_tags, -> { includes(:tags) }
scope :eager_load_everything, -> do
includes(
@@ -178,25 +178,6 @@ module Ci
joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types)
end
- scope :matches_tag_ids, -> (tag_ids) do
- matcher = ::ActsAsTaggableOn::Tagging
- .where(taggable_type: CommitStatus.name)
- .where(context: 'tags')
- .where('taggable_id = ci_builds.id')
- .where.not(tag_id: tag_ids).select('1')
-
- where("NOT EXISTS (?)", matcher)
- end
-
- scope :with_any_tags, -> do
- matcher = ::ActsAsTaggableOn::Tagging
- .where(taggable_type: CommitStatus.name)
- .where(context: 'tags')
- .where('taggable_id = ci_builds.id').select('1')
-
- where("EXISTS (?)", matcher)
- end
-
scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
scope :preload_project_and_pipeline_project, -> do
@@ -212,7 +193,7 @@ module Ci
acts_as_taggable
- add_authentication_token_field :token, encrypted: :optional
+ add_authentication_token_field :token, encrypted: :required
before_save :ensure_token
before_destroy { unscoped_project }
@@ -344,7 +325,11 @@ module Ci
build.run_after_commit do
build.run_status_commit_hooks!
- BuildFinishedWorker.perform_async(id)
+ if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project, default_enabled: :yaml)
+ Ci::BuildFinishedWorker.perform_async(id)
+ else
+ ::BuildFinishedWorker.perform_async(id)
+ end
end
end
@@ -758,6 +743,14 @@ module Ci
self.token && ActiveSupport::SecurityUtils.secure_compare(token, self.token)
end
+ def tag_list
+ if tags.loaded?
+ tags.map(&:name)
+ else
+ super
+ end
+ end
+
def has_tags?
tag_list.any?
end
@@ -782,7 +775,7 @@ module Ci
return unless project
project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks)
- project.execute_services(build_data.dup, :job_hooks) if project.has_active_services?(:job_hooks)
+ project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks)
end
def browsable_artifacts?
@@ -939,8 +932,7 @@ module Ci
end
def supports_artifacts_exclude?
- options&.dig(:artifacts, :exclude)&.any? &&
- Gitlab::Ci::Features.artifacts_exclude_enabled?
+ options&.dig(:artifacts, :exclude)&.any?
end
def multi_build_steps?
diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb
index d39e0411a79..c4a04d42a1e 100644
--- a/app/models/ci/build_dependencies.rb
+++ b/app/models/ci/build_dependencies.rb
@@ -37,12 +37,20 @@ module Ci
next [] unless processable.pipeline_id # we don't have any dependency when creating the pipeline
deps = model_class.where(pipeline_id: processable.pipeline_id).latest
- deps = from_previous_stages(deps)
- deps = from_needs(deps)
+ deps = find_dependencies(processable, deps)
+
from_dependencies(deps).to_a
end
end
+ def find_dependencies(processable, deps)
+ if processable.scheduling_type_dag?
+ from_needs(deps)
+ else
+ from_previous_stages(deps)
+ end
+ end
+
# Dependencies from the same parent-pipeline hierarchy excluding
# the current job's pipeline
def cross_pipeline
@@ -125,8 +133,6 @@ module Ci
end
def from_needs(scope)
- return scope unless processable.scheduling_type_dag?
-
needs_names = processable.needs.artifacts.select(:name)
scope.where(name: needs_names)
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index f009e4c6aa1..50775f578f0 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -22,8 +22,8 @@ module Ci
validates :build, presence: true
validates :secrets, json_schema: { filename: 'build_metadata_secrets' }
- serialize :config_options, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize
- serialize :config_variables, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :config_options, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :config_variables, Serializers::SymbolizedJson # rubocop:disable Cop/ActiveRecordSerialize
chronic_duration_attr_reader :timeout_human_readable, :timeout
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 25f4a06088d..3fa9a484b0c 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -14,13 +14,7 @@ module Ci
belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
- default_value_for :data_store do
- if Feature.enabled?(:dedicated_redis_trace_chunks, type: :ops)
- :redis_trace_chunks
- else
- :redis
- end
- end
+ default_value_for :data_store, :redis_trace_chunks
after_create { metrics.increment_trace_operation(operation: :chunked) }
@@ -115,7 +109,7 @@ module Ci
raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
return if offset == size # Skip the following process as it doesn't affect anything
- self.append("", offset)
+ self.append(+"", offset)
end
def append(new_data, offset)
diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb
index fab85fae33d..3bfac2b33c0 100644
--- a/app/models/ci/build_trace_chunks/fog.rb
+++ b/app/models/ci/build_trace_chunks/fog.rb
@@ -25,14 +25,36 @@ module Ci
files.create(create_attributes(model, new_data))
end
+ # This is the sequence that causes append_data to be called:
+ #
+ # 1. Runner sends a PUT /api/v4/jobs/:id to indicate the job is canceled or finished.
+ # 2. UpdateBuildStateService#accept_build_state! persists all live job logs to object storage (or filesystem).
+ # 3. UpdateBuildStateService#accept_build_state! returns a 202 to the runner.
+ # 4. The runner continues to send PATCH requests with job logs until all logs have been sent and received.
+ # 5. If the last PATCH request arrives after the job log has been persisted, we
+ # retrieve the data from object storage to append the remaining lines.
def append_data(model, new_data, offset)
if offset > 0
truncated_data = data(model).to_s.byteslice(0, offset)
- new_data = truncated_data + new_data
+ new_data = append_strings(truncated_data, new_data)
end
set_data(model, new_data)
new_data.bytesize
+ rescue Encoding::CompatibilityError => e
+ Gitlab::ErrorTracking.track_and_raise_exception(
+ e,
+ build_id: model.build_id,
+ chunk_index: model.chunk_index,
+ chunk_start_offset: model.start_offset,
+ chunk_end_offset: model.end_offset,
+ chunk_size: model.size,
+ chunk_data_store: model.data_store,
+ offset: offset,
+ old_data_encoding: truncated_data.encoding.to_s,
+ new_data: new_data,
+ new_data_size: new_data.bytesize,
+ new_data_encoding: new_data.encoding.to_s)
end
def size(model)
@@ -57,6 +79,17 @@ module Ci
private
+ def append_strings(old_data, new_data)
+ if Feature.enabled?(:ci_job_trace_force_encode, default_enabled: :yaml)
+ # When object storage is in use, old_data may be retrieved in UTF-8.
+ old_data = old_data.force_encoding(Encoding::ASCII_8BIT)
+ # new_data should already be in ASCII-8BIT, but just in case it isn't, do this.
+ new_data = new_data.force_encoding(Encoding::ASCII_8BIT)
+ end
+
+ old_data + new_data
+ end
+
def key(model)
key_raw(model.build_id, model.chunk_index)
end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 47b91fcf2ce..e5cb2026503 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -10,6 +10,7 @@ module Ci
class Group
include StaticModel
include Gitlab::Utils::StrongMemoize
+ include GlobalID::Identification
attr_reader :project, :stage, :name, :jobs
@@ -22,6 +23,10 @@ module Ci
@jobs = jobs
end
+ def id
+ "#{stage.id}-#{name}"
+ end
+
def ==(other)
other.present? && other.is_a?(self.class) &&
project == other.project &&
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index e083caa8751..5aee4c924af 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class InstanceVariable < ApplicationRecord
+ class InstanceVariable < ::Ci::BaseModel
extend Gitlab::Ci::Model
extend Gitlab::ProcessMemoryCache::Helper
include Ci::NewHasVariable
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 6a7a2b3f6bd..46c976d5616 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -33,6 +33,7 @@ module Ci
secret_detection: 'gl-secret-detection-report.json',
dependency_scanning: 'gl-dependency-scanning-report.json',
container_scanning: 'gl-container-scanning-report.json',
+ cluster_image_scanning: 'gl-cluster-image-scanning-report.json',
dast: 'gl-dast-report.json',
license_scanning: 'gl-license-scanning-report.json',
performance: 'performance.json',
@@ -71,6 +72,7 @@ module Ci
secret_detection: :raw,
dependency_scanning: :raw,
container_scanning: :raw,
+ cluster_image_scanning: :raw,
dast: :raw,
license_scanning: :raw,
@@ -108,6 +110,7 @@ module Ci
sast
secret_detection
requirements
+ cluster_image_scanning
].freeze
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
@@ -181,6 +184,8 @@ module Ci
scope :with_destroy_preloads, -> { includes(project: [:route, :statistics]) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
+ scope :for_project, ->(project) { where(project_id: project) }
+ scope :created_in_time_range, ->(from: nil, to: nil) { where(created_at: from..to) }
delegate :filename, :exists?, :open, to: :file
@@ -210,7 +215,8 @@ module Ci
coverage_fuzzing: 23, ## EE-specific
browser_performance: 24, ## EE-specific
load_performance: 25, ## EE-specific
- api_fuzzing: 26 ## EE-specific
+ api_fuzzing: 26, ## EE-specific
+ cluster_image_scanning: 27 ## EE-specific
}
# `file_location` indicates where actual files are stored.
diff --git a/app/models/ci/job_token/project_scope_link.rb b/app/models/ci/job_token/project_scope_link.rb
index 283ad4a190d..99118f8090b 100644
--- a/app/models/ci/job_token/project_scope_link.rb
+++ b/app/models/ci/job_token/project_scope_link.rb
@@ -19,6 +19,10 @@ module Ci
validates :target_project, presence: true
validate :not_self_referential_link
+ def self.for_source_and_target(source_project, target_project)
+ self.find_by(source_project: source_project, target_project: target_project)
+ end
+
private
def not_self_referential_link
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index b9a8a44bd6b..0663052f51d 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -7,12 +7,52 @@ module Ci
belongs_to :project
belongs_to :build, class_name: 'Ci::Build'
+ scope :ref_protected, -> { where(protected: true) }
+ scope :queued_before, ->(time) { where(arel_table[:created_at].lt(time)) }
+
def self.upsert_from_build!(build)
- entry = self.new(build: build, project: build.project, protected: build.protected?)
+ entry = self.new(args_from_build(build))
entry.validate!
self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
end
+
+ def self.args_from_build(build)
+ args = {
+ build: build,
+ project: build.project,
+ protected: build.protected?
+ }
+
+ if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml)
+ args.merge(instance_runners_enabled: shareable?(build))
+ else
+ args
+ end
+ end
+ private_class_method :args_from_build
+
+ def self.shareable?(build)
+ shared_runner_enabled?(build) &&
+ builds_access_level?(build) &&
+ project_not_removed?(build)
+ end
+ private_class_method :shareable?
+
+ def self.shared_runner_enabled?(build)
+ build.project.shared_runners.exists?
+ end
+ private_class_method :shared_runner_enabled?
+
+ def self.project_not_removed?(build)
+ !build.project.pending_delete?
+ end
+ private_class_method :project_not_removed?
+
+ def self.builds_access_level?(build)
+ build.project.project_feature.builds_access_level.nil? || build.project.project_feature.builds_access_level > 0
+ end
+ private_class_method :builds_access_level?
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 159d9d10878..5d079f57267 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -29,6 +29,8 @@ module Ci
BridgeStatusError = Class.new(StandardError)
+ paginates_per 15
+
sha_attribute :source_sha
sha_attribute :target_sha
@@ -222,7 +224,7 @@ module Ci
end
after_transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success do |pipeline|
- # We wait a little bit to ensure that all BuildFinishedWorkers finish first
+ # We wait a little bit to ensure that all Ci::BuildFinishedWorkers finish first
# because this is where some metrics like code coverage is parsed and stored
# in CI build records which the daily build metrics worker relies on.
pipeline.run_after_commit { Ci::DailyBuildGroupReportResultsWorker.perform_in(10.minutes, pipeline.id) }
@@ -577,11 +579,11 @@ module Ci
canceled? && auto_canceled_by_id?
end
- def cancel_running(retries: nil)
+ def cancel_running(retries: 1)
commit_status_relations = [:project, :pipeline]
ci_build_relations = [:deployment, :taggings]
- retry_optimistic_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables|
+ retry_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables|
cancelables.find_in_batches do |batch|
ActiveRecord::Associations::Preloader.new.preload(batch, commit_status_relations)
ActiveRecord::Associations::Preloader.new.preload(batch.select { |job| job.is_a?(Ci::Build) }, ci_build_relations)
@@ -594,7 +596,7 @@ module Ci
end
end
- def auto_cancel_running(pipeline, retries: nil)
+ def auto_cancel_running(pipeline, retries: 1)
update(auto_canceled_by: pipeline)
cancel_running(retries: retries) do |job|
@@ -610,8 +612,6 @@ module Ci
# rubocop: enable CodeReuse/ServiceClass
def lazy_ref_commit
- return unless ::Gitlab::Ci::Features.pipeline_latest?
-
BatchLoader.for(ref).batch do |refs, loader|
next unless project.repository_exists?
@@ -623,11 +623,6 @@ module Ci
def latest?
return false unless git_ref && commit.present?
-
- unless ::Gitlab::Ci::Features.pipeline_latest?
- return project.commit(git_ref) == commit
- end
-
return false if lazy_ref_commit.nil?
lazy_ref_commit.id == commit.id
@@ -861,7 +856,7 @@ module Ci
def execute_hooks
project.execute_hooks(pipeline_data, :pipeline_hooks) if project.has_active_hooks?(:pipeline_hooks)
- project.execute_services(pipeline_data, :pipeline_hooks) if project.has_active_services?(:pipeline_hooks)
+ project.execute_integrations(pipeline_data, :pipeline_hooks) if project.has_active_integrations?(:pipeline_hooks)
end
# All the merge requests for which the current pipeline runs/ran against
@@ -911,7 +906,7 @@ module Ci
def same_family_pipeline_ids
::Gitlab::Ci::PipelineObjectHierarchy.new(
- self.class.default_scoped.where(id: root_ancestor), options: { same_project: true }
+ self.class.default_scoped.where(id: root_ancestor), options: { project_condition: :same }
).base_and_descendants.select(:id)
end
@@ -932,29 +927,34 @@ module Ci
Environment.where(id: environment_ids)
end
- # Without using `unscoped`, caller scope is also included into the query.
- # Using `unscoped` here will be redundant after Rails 6.1
+ # With multi-project and parent-child pipelines
+ def self_and_upstreams
+ object_hierarchy.base_and_ancestors
+ end
+
+ # With multi-project and parent-child pipelines
+ def self_with_upstreams_and_downstreams
+ object_hierarchy.all_objects
+ end
+
+ # With only parent-child pipelines
+ def self_and_ancestors
+ object_hierarchy(project_condition: :same).base_and_ancestors
+ end
+
+ # With only parent-child pipelines
def self_and_descendants
- ::Gitlab::Ci::PipelineObjectHierarchy
- .new(self.class.unscoped.where(id: id), options: { same_project: true })
- .base_and_descendants
+ object_hierarchy(project_condition: :same).base_and_descendants
end
def root_ancestor
return self unless child?
- Gitlab::Ci::PipelineObjectHierarchy
- .new(self.class.unscoped.where(id: id), options: { same_project: true })
+ object_hierarchy(project_condition: :same)
.base_and_ancestors(hierarchy_order: :desc)
.first
end
- def self_with_ancestors_and_descendants(same_project: false)
- ::Gitlab::Ci::PipelineObjectHierarchy
- .new(self.class.unscoped.where(id: id), options: { same_project: same_project })
- .all_objects
- end
-
def bridge_triggered?
source_bridge.present?
end
@@ -1026,8 +1026,6 @@ module Ci
end
def can_generate_codequality_reports?
- return false unless ::Gitlab::Ci::Features.display_quality_on_mr_diff?(project)
-
has_reports?(Ci::JobArtifact.codequality_reports)
end
@@ -1214,14 +1212,6 @@ module Ci
self.ci_ref = Ci::Ref.ensure_for(self)
end
- def base_and_ancestors(same_project: false)
- # Without using `unscoped`, caller scope is also included into the query.
- # Using `unscoped` here will be redundant after Rails 6.1
- ::Gitlab::Ci::PipelineObjectHierarchy
- .new(self.class.unscoped.where(id: id), options: { same_project: same_project })
- .base_and_ancestors
- end
-
# We need `base_and_ancestors` in a specific order to "break" when needed.
# If we use `find_each`, then the order is broken.
# rubocop:disable Rails/FindEach
@@ -1232,7 +1222,7 @@ module Ci
source_bridge.pending!
Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass
else
- base_and_ancestors.includes(:source_bridge).each do |pipeline|
+ self_and_upstreams.includes(:source_bridge).each do |pipeline|
break unless pipeline.bridge_waiting?
pipeline.source_bridge.pending!
@@ -1315,6 +1305,13 @@ module Ci
project.repository.keep_around(self.sha, self.before_sha)
end
+
+ # Without using `unscoped`, caller scope is also included into the query.
+ # Using `unscoped` here will be redundant after Rails 6.1
+ def object_hierarchy(options = {})
+ ::Gitlab::Ci::PipelineObjectHierarchy
+ .new(self.class.unscoped.where(id: id), options: options)
+ end
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 71110ef0696..a541dca47de 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -13,7 +13,7 @@ module Ci
include Gitlab::Utils::StrongMemoize
include TaggableQueries
- add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
+ add_authentication_token_field :token, encrypted: :optional
enum access_level: {
not_protected: 0,
@@ -214,15 +214,15 @@ module Ci
Arel.sql("(#{arel_tag_names_array.to_sql})")
]
- # we use distinct to de-duplicate data
- distinct.pluck(*unique_params).map do |values|
+ group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values|
Gitlab::Ci::Matching::RunnerMatcher.new({
- runner_type: values[0],
- public_projects_minutes_cost_factor: values[1],
- private_projects_minutes_cost_factor: values[2],
- run_untagged: values[3],
- access_level: values[4],
- tag_list: values[5]
+ runner_ids: values[0],
+ runner_type: values[1],
+ public_projects_minutes_cost_factor: values[2],
+ private_projects_minutes_cost_factor: values[3],
+ run_untagged: values[4],
+ access_level: values[5],
+ tag_list: values[6]
})
end
end
@@ -230,6 +230,7 @@ module Ci
def runner_matcher
strong_memoize(:runner_matcher) do
Gitlab::Ci::Matching::RunnerMatcher.new({
+ runner_ids: [id],
runner_type: runner_type,
public_projects_minutes_cost_factor: public_projects_minutes_cost_factor,
private_projects_minutes_cost_factor: private_projects_minutes_cost_factor,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 49840e3a2e7..3785023c9af 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.29.0'
+ VERSION = '0.30.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb
index 0a01ac5d1ce..3f2c47d48e6 100644
--- a/app/models/clusters/integrations/prometheus.rb
+++ b/app/models/clusters/integrations/prometheus.rb
@@ -23,7 +23,7 @@ module Clusters
after_destroy do
run_after_commit do
- deactivate_project_services
+ deactivate_project_integrations
end
end
@@ -32,9 +32,9 @@ module Clusters
run_after_commit do
if enabled
- activate_project_services
+ activate_project_integrations
else
- deactivate_project_services
+ deactivate_project_integrations
end
end
end
@@ -45,14 +45,14 @@ module Clusters
private
- def activate_project_services
+ def activate_project_integrations
::Clusters::Applications::ActivateServiceWorker
- .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
+ .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
end
- def deactivate_project_services
+ def deactivate_project_integrations
::Clusters::Applications::DeactivateServiceWorker
- .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
+ .perform_async(cluster_id, ::Integrations::Prometheus.to_param)
end
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2db606898b9..cf23cd3be67 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -7,6 +7,7 @@ class CommitStatus < ApplicationRecord
include Presentable
include EnumWithNil
include BulkInsertableAssociations
+ include TaggableQueries
self.table_name = 'ci_builds'
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 9b214171f07..2eaaf98c260 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -25,6 +25,10 @@ class Compare
@straight = straight
end
+ def cache_key
+ [@project, :compare, diff_refs.hash]
+ end
+
def commits
@commits ||= Commit.decorate(@compare.commits, project)
end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 90d48aa81d0..2a0274f5706 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -50,6 +50,10 @@ module Analytics
end
end
+ def events_hash_code
+ Digest::SHA256.hexdigest("#{start_event.hash_code}-#{end_event.hash_code}")
+ end
+
def start_event_label_based?
start_event_identifier && start_event.label_based?
end
diff --git a/app/models/concerns/any_field_validation.rb b/app/models/concerns/any_field_validation.rb
new file mode 100644
index 00000000000..987c4e7800e
--- /dev/null
+++ b/app/models/concerns/any_field_validation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# This module enables a record to be valid if any field is present
+#
+# Overwrite one_of_required_fields to set one of which fields must be present
+module AnyFieldValidation
+ extend ActiveSupport::Concern
+
+ included do
+ validate :any_field_present
+ end
+
+ private
+
+ def any_field_present
+ return unless one_of_required_fields.all? { |field| self[field].blank? }
+
+ errors.add(:base, _("At least one field of %{one_of_required_fields} must be present") %
+ { one_of_required_fields: one_of_required_fields })
+ end
+
+ def one_of_required_fields
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb
index c2d94b50f8d..ef7ba7b1089 100644
--- a/app/models/concerns/approvable_base.rb
+++ b/app/models/concerns/approvable_base.rb
@@ -24,6 +24,19 @@ module ApprovableBase
.group(:id)
.having("COUNT(users.id) = ?", usernames.size)
end
+
+ scope :not_approved_by_users_with_usernames, -> (usernames) do
+ users = User.where(username: usernames).select(:id)
+ self_table = self.arel_table
+ app_table = Approval.arel_table
+
+ where(
+ Approval.where(approvals: { user_id: users })
+ .where(app_table[:merge_request_id].eq(self_table[:id]))
+ .select('true')
+ .arel.exists.not
+ )
+ end
end
class_methods do
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 80cf6260b0b..88f577c3e23 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -159,9 +159,8 @@ module AtomicInternalId
# Defines class methods:
#
# - with_{scope}_{column}_supply
- # This method can be used to allocate a block of IID values during
- # bulk operations (importing/copying, etc). This can be more efficient
- # than creating instances one-by-one.
+ # This method can be used to allocate a stream of IID values during
+ # bulk operations (importing/copying, etc).
#
# Pass in a block that receives a `Supply` instance. To allocate a new
# IID value, call `Supply#next_value`.
@@ -181,14 +180,8 @@ module AtomicInternalId
scope_attrs = ::AtomicInternalId.scope_attrs(scope_value)
usage = ::AtomicInternalId.scope_usage(self)
- generator = InternalId::InternalIdGenerator.new(subject, scope_attrs, usage, init)
-
- generator.with_lock do
- supply = Supply.new(generator.record.last_value)
- block.call(supply)
- ensure
- generator.track_greatest(supply.current_value) if supply
- end
+ supply = Supply.new(-> { InternalId.generate_next(subject, scope_attrs, usage, init) })
+ block.call(supply)
end
end
end
@@ -236,14 +229,14 @@ module AtomicInternalId
end
class Supply
- attr_reader :current_value
+ attr_reader :generator
- def initialize(start_value)
- @current_value = start_value
+ def initialize(generator)
+ @generator = generator
end
def next_value
- @current_value += 1
+ @generator.call
end
end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index fdc418029be..84a74386ff7 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -9,13 +9,18 @@ module Avatarable
ALLOWED_IMAGE_SCALER_WIDTHS = (USER_AVATAR_SIZES | PROJECT_AVATAR_SIZES | GROUP_AVATAR_SIZES).freeze
+ # This value must not be bigger than then: https://gitlab.com/gitlab-org/gitlab/-/blob/master/workhorse/config.toml.example#L20
+ #
+ # https://docs.gitlab.com/ee/development/image_scaling.html
+ MAXIMUM_FILE_SIZE = 200.kilobytes.to_i
+
included do
prepend ShadowMethods
include ObjectStorage::BackgroundMove
include Gitlab::Utils::StrongMemoize
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
- validates :avatar, file_size: { maximum: 200.kilobytes.to_i }, if: :avatar_changed?
+ validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed?
mount_uploader :avatar, AvatarUploader
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 101bff32dfe..79b622c8dad 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -163,9 +163,9 @@ module CacheMarkdownField
refs = all_references(self.author)
references = {}
- references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
- references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
- references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
+ references[:mentioned_users_ids] = refs.mentioned_user_ids.presence
+ references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence
+ references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence
# One retry is enough as next time `model_user_mention` should return the existing mention record,
# that threw the `ActiveRecord::RecordNotUnique` exception in first place.
diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb
index 9efd90756b1..5d24e15d518 100644
--- a/app/models/concerns/cascading_namespace_setting_attribute.rb
+++ b/app/models/concerns/cascading_namespace_setting_attribute.rb
@@ -24,10 +24,6 @@ module CascadingNamespaceSettingAttribute
include Gitlab::Utils::StrongMemoize
class_methods do
- def cascading_settings_feature_enabled?
- ::Feature.enabled?(:cascading_namespace_settings, default_enabled: true)
- end
-
private
# Facilitates the cascading lookup of values and,
@@ -82,8 +78,6 @@ module CascadingNamespaceSettingAttribute
def define_attr_reader(attribute)
define_method(attribute) do
strong_memoize(attribute) do
- next self[attribute] unless self.class.cascading_settings_feature_enabled?
-
next self[attribute] if will_save_change_to_attribute?(attribute)
next locked_value(attribute) if cascading_attribute_locked?(attribute, include_self: false)
next self[attribute] unless self[attribute].nil?
@@ -189,7 +183,6 @@ module CascadingNamespaceSettingAttribute
end
def locked_ancestor(attribute)
- return unless self.class.cascading_settings_feature_enabled?
return unless namespace.has_parent?
strong_memoize(:"#{attribute}_locked_ancestor") do
@@ -202,14 +195,10 @@ module CascadingNamespaceSettingAttribute
end
def locked_by_ancestor?(attribute)
- return false unless self.class.cascading_settings_feature_enabled?
-
locked_ancestor(attribute).present?
end
def locked_by_application_setting?(attribute)
- return false unless self.class.cascading_settings_feature_enabled?
-
Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend
end
@@ -241,7 +230,7 @@ module CascadingNamespaceSettingAttribute
def namespace_ancestor_ids
strong_memoize(:namespace_ancestor_ids) do
- namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id).reject { |id| id == namespace_id }
+ namespace.ancestor_ids(hierarchy_order: :asc)
end
end
diff --git a/app/models/concerns/ci/maskable.rb b/app/models/concerns/ci/maskable.rb
index e1ef4531845..62be0150ee0 100644
--- a/app/models/concerns/ci/maskable.rb
+++ b/app/models/concerns/ci/maskable.rb
@@ -11,7 +11,7 @@ module Ci
# * Minimal length of 8 characters
# * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~'
# * Absolutely no fun is allowed
- REGEX = /\A[a-zA-Z0-9_+=\/@:.~-]{8,}\z/.freeze
+ REGEX = %r{\A[a-zA-Z0-9_+=/@:.~-]{8,}\z}.freeze
included do
validates :masked, inclusion: { in: [true, false] }
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index 601637ea32a..114435d5a21 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -77,7 +77,7 @@ module Ci
def write_metadata_attribute(legacy_key, metadata_key, value)
# save to metadata or this model depending on the state of feature flag
- if Feature.enabled?(:ci_build_metadata_config)
+ if Feature.enabled?(:ci_build_metadata_config, project, default_enabled: :yaml)
ensure_metadata.write_attribute(metadata_key, value)
write_attribute(legacy_key, nil)
else
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 72788d15c0a..16dec5fb081 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -25,6 +25,7 @@ module Enums
ci_quota_exceeded: 16,
pipeline_loop_detected: 17,
no_matching_runner: 18, # not used anymore, but cannot be deleted because of old data
+ trace_size_exceeded: 19,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/has_integrations.rb b/app/models/concerns/has_integrations.rb
index b2775f4cbb2..25650ae56ad 100644
--- a/app/models/concerns/has_integrations.rb
+++ b/app/models/concerns/has_integrations.rb
@@ -19,7 +19,7 @@ module HasIntegrations
def without_integration(integration)
integrations = Integration
.select('1')
- .where('services.project_id = projects.id')
+ .where("#{Integration.table_name}.project_id = projects.id")
.where(type: integration.type)
Project
diff --git a/app/models/concerns/integrations/has_web_hook.rb b/app/models/concerns/integrations/has_web_hook.rb
new file mode 100644
index 00000000000..dabe7152b18
--- /dev/null
+++ b/app/models/concerns/integrations/has_web_hook.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HasWebHook
+ extend ActiveSupport::Concern
+
+ included do
+ after_save :update_web_hook!, if: :activated?
+ end
+
+ # Return the URL to be used for the webhook.
+ def hook_url
+ raise NotImplementedError
+ end
+
+ # Return whether the webhook should use SSL verification.
+ def hook_ssl_verification
+ true
+ end
+
+ # Create or update the webhook, raising an exception if it cannot be saved.
+ def update_web_hook!
+ hook = service_hook || build_service_hook
+ hook.url = hook_url if hook.url != hook_url # avoid reencryption
+ hook.enable_ssl_verification = hook_ssl_verification
+ hook.save! if hook.changed?
+ hook
+ end
+
+ # Execute the webhook, creating it if necessary.
+ def execute_web_hook!(*args)
+ update_web_hook!
+ service_hook.execute(*args)
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 2d06247a486..d5e2e63402f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -31,6 +31,7 @@ module Issuable
TITLE_HTML_LENGTH_MAX = 800
DESCRIPTION_LENGTH_MAX = 1.megabyte
DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes
+ SEARCHABLE_FIELDS = %w(title description).freeze
STATE_ID_MAP = {
opened: 1,
@@ -264,15 +265,16 @@ module Issuable
# matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
#
# Returns an ActiveRecord::Relation.
- def full_search(query, matched_columns: 'title,description', use_minimum_char_limit: true)
- allowed_columns = [:title, :description]
- matched_columns = matched_columns.to_s.split(',').map(&:to_sym)
- matched_columns &= allowed_columns
+ def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
+ if matched_columns
+ matched_columns = matched_columns.to_s.split(',')
+ matched_columns &= SEARCHABLE_FIELDS
+ matched_columns.map!(&:to_sym)
+ end
- # Matching title or description if the matched_columns did not contain any allowed columns.
- matched_columns = [:title, :description] if matched_columns.empty?
+ search_columns = matched_columns.presence || [:title, :description]
- fuzzy_search(query, matched_columns, use_minimum_char_limit: use_minimum_char_limit)
+ fuzzy_search(query, search_columns, use_minimum_char_limit: use_minimum_char_limit)
end
def simple_sorts
@@ -330,12 +332,15 @@ module Issuable
# When using CTE make sure to select the same columns that are on the group_by clause.
# This prevents errors when ignored columns are present in the database.
issuable_columns = with_cte ? issue_grouping_columns(use_cte: with_cte) : "#{table_name}.*"
+ group_columns = issue_grouping_columns(use_cte: with_cte) + ["highest_priorities.label_priority"]
- extra_select_columns.unshift("(#{highest_priority}) AS highest_priority")
+ extra_select_columns.unshift("highest_priorities.label_priority as highest_priority")
select(issuable_columns)
.select(extra_select_columns)
- .group(issue_grouping_columns(use_cte: with_cte))
+ .from("#{table_name}")
+ .joins("JOIN LATERAL(#{highest_priority}) as highest_priorities ON TRUE")
+ .group(group_columns)
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
@@ -382,7 +387,7 @@ module Issuable
if use_cte
attribute_names.map { |attr| arel_table[attr.to_sym] }
else
- arel_table[:id]
+ [arel_table[:id]]
end
end
@@ -457,6 +462,7 @@ module Issuable
if old_associations
old_labels = old_associations.fetch(:labels, labels)
old_assignees = old_associations.fetch(:assignees, assignees)
+ old_severity = old_associations.fetch(:severity, severity)
if old_labels != labels
changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
@@ -466,6 +472,10 @@ module Issuable
changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
end
+ if supports_severity? && old_severity != severity
+ changes[:severity] = [old_severity, severity]
+ end
+
if self.respond_to?(:total_time_spent)
old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
old_time_change = old_associations.fetch(:time_change, time_change)
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index eaf64f2541d..4f2ea58f36d 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -101,6 +101,10 @@ module Milestoneish
due_date && due_date.past?
end
+ def expired
+ expired? || false
+ end
+
def total_time_spent
@total_time_spent ||= issues.joins(:timelogs).sum(:time_spent) + merge_requests.joins(:timelogs).sum(:time_spent)
end
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
index 9f1cec5d520..eab5d4c35bb 100644
--- a/app/models/concerns/partitioned_table.rb
+++ b/app/models/concerns/partitioned_table.rb
@@ -10,12 +10,12 @@ module PartitionedTable
monthly: Gitlab::Database::Partitioning::MonthlyStrategy
}.freeze
- def partitioned_by(partitioning_key, strategy:)
+ def partitioned_by(partitioning_key, strategy:, **kwargs)
strategy_class = PARTITIONING_STRATEGIES[strategy.to_sym] || raise(ArgumentError, "Unknown partitioning strategy: #{strategy}")
- @partitioning_strategy = strategy_class.new(self, partitioning_key)
+ @partitioning_strategy = strategy_class.new(self, partitioning_key, **kwargs)
- Gitlab::Database::Partitioning::PartitionCreator.register(self)
+ Gitlab::Database::Partitioning::PartitionManager.register(self)
end
end
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 9f5e9b2bb57..65fb62a814f 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -46,7 +46,7 @@ module Sortable
private
def highest_label_priority(target_type_column: nil, target_type: nil, target_column:, project_column:, excluded_labels: [])
- query = Label.select(LabelPriority.arel_table[:priority].minimum)
+ query = Label.select(LabelPriority.arel_table[:priority].minimum.as('label_priority'))
.left_join_priorities
.joins(:label_links)
.where("label_priorities.project_id = #{project_column}")
diff --git a/app/models/concerns/taggable_queries.rb b/app/models/concerns/taggable_queries.rb
index 2897e5e6420..cba2e93a86d 100644
--- a/app/models/concerns/taggable_queries.rb
+++ b/app/models/concerns/taggable_queries.rb
@@ -12,5 +12,26 @@ module TaggableQueries
.where(taggings: { context: context, taggable_type: polymorphic_name })
.select('COALESCE(array_agg(tags.name ORDER BY name), ARRAY[]::text[])')
end
+
+ def matches_tag_ids(tag_ids, table: quoted_table_name, column: 'id')
+ matcher = ::ActsAsTaggableOn::Tagging
+ .where(taggable_type: CommitStatus.name)
+ .where(context: 'tags')
+ .where("taggable_id = #{connection.quote_table_name(table)}.#{connection.quote_column_name(column)}") # rubocop:disable GitlabSecurity/SqlInjection
+ .where.not(tag_id: tag_ids)
+ .select('1')
+
+ where("NOT EXISTS (?)", matcher)
+ end
+
+ def with_any_tags(table: quoted_table_name, column: 'id')
+ matcher = ::ActsAsTaggableOn::Tagging
+ .where(taggable_type: CommitStatus.name)
+ .where(context: 'tags')
+ .where("taggable_id = #{connection.quote_table_name(table)}.#{connection.quote_column_name(column)}") # rubocop:disable GitlabSecurity/SqlInjection
+ .select('1')
+
+ where("EXISTS (?)", matcher)
+ end
end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 2d28a81f462..8e130998f11 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -24,15 +24,8 @@ class ContainerRepository < ApplicationRecord
scope :for_group_and_its_subgroups, ->(group) do
project_scope = Project
.for_group_and_its_subgroups(group)
-
- project_scope =
- if Feature.enabled?(:read_container_registry_access_level, group, default_enabled: :yaml)
- project_scope.with_feature_enabled(:container_registry)
- else
- project_scope.with_container_registry
- end
-
- project_scope = project_scope.select(:id)
+ .with_feature_enabled(:container_registry)
+ .select(:id)
joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id")
end
@@ -153,7 +146,8 @@ class ContainerRepository < ApplicationRecord
end
def self.create_from_path!(path)
- build_from_path(path).tap(&:save!)
+ safe_find_or_create_by!(project: path.repository_project,
+ name: path.repository_name)
end
def self.build_root_repository(project)
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 7f5849bffc6..313aeb1eda7 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -189,7 +189,7 @@ class Deployment < ApplicationRecord
def execute_hooks(status_changed_at)
deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at)
project.execute_hooks(deployment_data, :deployment_hooks)
- project.execute_services(deployment_data, :deployment_hooks)
+ project.execute_integrations(deployment_data, :deployment_hooks)
end
def last?
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 6806008d676..642e93f7912 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -42,6 +42,13 @@ class DiffDiscussion < Discussion
)
end
+ def cache_key
+ [
+ super,
+ Digest::SHA1.hexdigest(position.to_json)
+ ].join(':')
+ end
+
private
def get_params
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 70aa02063cc..076d8cc280c 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -7,6 +7,9 @@ class Discussion
include GlobalID::Identification
include ResolvableDiscussion
+ # Bump this if we need to refresh the cached versions of discussions
+ CACHE_VERSION = 1
+
attr_reader :notes, :context_noteable
delegate :created_at,
@@ -158,4 +161,19 @@ class Discussion
def reply_attributes
first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id)
end
+
+ def cache_key
+ # Need this so cache will be invalidated when note within a discussion
+ # has been deleted.
+ notes_sha = Digest::SHA1.hexdigest(notes.map(&:id).join(':'))
+
+ [
+ CACHE_VERSION,
+ notes.last.latest_cached_markdown_version,
+ id,
+ notes_sha,
+ notes.max_by(&:updated_at).updated_at,
+ resolved_at
+ ].join(':')
+ end
end
diff --git a/app/models/error_tracking.rb b/app/models/error_tracking.rb
new file mode 100644
index 00000000000..20729330088
--- /dev/null
+++ b/app/models/error_tracking.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ def self.table_name_prefix
+ 'error_tracking_'
+ end
+end
diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb
new file mode 100644
index 00000000000..012dcc4418f
--- /dev/null
+++ b/app/models/error_tracking/error.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ErrorTracking::Error < ApplicationRecord
+ belongs_to :project
+
+ has_many :events, class_name: 'ErrorTracking::ErrorEvent'
+
+ validates :project, presence: true
+ validates :name, presence: true
+ validates :description, presence: true
+ validates :actor, presence: true
+
+ def self.report_error(name:, description:, actor:, platform:, timestamp:)
+ safe_find_or_create_by(
+ name: name,
+ description: description,
+ actor: actor,
+ platform: platform
+ ) do |error|
+ error.update!(last_seen_at: timestamp)
+ end
+ end
+end
diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb
new file mode 100644
index 00000000000..ed14a1bce41
--- /dev/null
+++ b/app/models/error_tracking/error_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ErrorTracking::ErrorEvent < ApplicationRecord
+ belongs_to :error, counter_cache: :events_count
+
+ validates :payload, json_schema: { filename: 'error_tracking_event_payload' }
+
+ validates :error, presence: true
+ validates :description, presence: true
+ validates :occurred_at, presence: true
+end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 956b5d6470f..c729b002852 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -24,6 +24,8 @@ module ErrorTracking
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
self.reactive_cache_work_type = :external_dependency
+ self.table_name = 'project_error_tracking_settings'
+
belongs_to :project
validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
diff --git a/app/models/event.rb b/app/models/event.rb
index 5b755736f47..14d20b0d6c4 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -24,15 +24,14 @@ class Event < ApplicationRecord
left: 9, # User left project
destroyed: 10,
expired: 11, # User left project due to expiry
- approved: 12,
- archived: 13 # Recoverable deletion
+ approved: 12
).freeze
private_constant :ACTIONS
WIKI_ACTIONS = [:created, :updated, :destroyed].freeze
- DESIGN_ACTIONS = [:created, :updated, :destroyed, :archived].freeze
+ DESIGN_ACTIONS = [:created, :updated, :destroyed].freeze
TARGET_TYPES = HashWithIndifferentAccess.new(
issue: Issue,
@@ -390,16 +389,15 @@ class Event < ApplicationRecord
read_snippet: %i[personal_snippet_note? project_snippet_note?],
read_milestone: %i[milestone?],
read_wiki: %i[wiki_page?],
- read_design: %i[design_note? design?]
+ read_design: %i[design_note? design?],
+ read_note: %i[note?]
}
end
private
def permission_object
- if note?
- note_target
- elsif target_id.present?
+ if target_id.present?
target
else
project
@@ -438,8 +436,7 @@ class Event < ApplicationRecord
{
created: _('uploaded'),
updated: _('revised'),
- destroyed: _('deleted'),
- archived: _('archived')
+ destroyed: _('deleted')
}
end
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index 4768506b8fa..f799377a15f 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -59,6 +59,7 @@ class EventCollection
parents_for_lateral = parents.select(:id).to_sql
lateral = filtered_events
+ # Applying the limit here (before we filter (permissions) means we may get less than limit)
.limit(limit_for_join_lateral)
.where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection
.to_sql
diff --git a/app/models/group.rb b/app/models/group.rb
index e4127b2b2d4..eefb8d3d16a 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -167,7 +167,7 @@ class Group < Namespace
def without_integration(integration)
integrations = Integration
.select('1')
- .where('services.group_id = namespaces.id')
+ .where("#{Integration.table_name}.group_id = namespaces.id")
.where(type: integration.type)
where('NOT EXISTS (?)', integrations)
@@ -296,7 +296,7 @@ class Group < Namespace
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
- GroupMember.add_users(
+ Members::Groups::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
self,
users,
access_level,
@@ -306,14 +306,13 @@ class Group < Namespace
end
def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false)
- GroupMember.add_user(
- self,
- user,
- access_level,
- current_user: current_user,
- expires_at: expires_at,
- ldap: ldap
- )
+ Members::Groups::CreatorService.new(self, # rubocop:todo CodeReuse/ServiceClass
+ user,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at,
+ ldap: ldap)
+ .execute
end
def add_guest(user, current_user = nil)
@@ -667,7 +666,7 @@ class Group < Namespace
# TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
end
- def execute_services(data, hooks_scope)
+ def execute_integrations(data, hooks_scope)
# NOOP
# TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
end
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index 0c96d5d4b6d..8c0565e4a38 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -9,7 +9,7 @@ class WebHookLog < ApplicationRecord
self.primary_key = :id
- partitioned_by :created_at, strategy: :monthly
+ partitioned_by :created_at, strategy: :monthly, retain_for: 3.months
belongs_to :web_hook
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 2fbcdc7f1cb..ea1e3840f6c 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -10,18 +10,15 @@ class Integration < ApplicationRecord
include FromUnion
include EachBatch
- # TODO Rename the table: https://gitlab.com/gitlab-org/gitlab/-/issues/201856
- self.table_name = 'services'
-
INTEGRATION_NAMES = %w[
- asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
+ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
- datadog jenkins
+ jenkins
].freeze
# Fake integrations to help with local development.
@@ -38,22 +35,6 @@ class Integration < ApplicationRecord
Integrations::BaseSlashCommands
].freeze
- # used as part of the renaming effort (https://gitlab.com/groups/gitlab-org/-/epics/2504)
- RENAMED_TO_INTEGRATION = %w[
- asana assembla
- bamboo bugzilla buildkite
- campfire confluence custom_issue_tracker
- datadog discord drone_ci
- emails_on_push ewm emails_on_push external_wiki
- flowdock
- hangouts_chat
- irker
- ].to_set.freeze
-
- def self.renamed?(name)
- RENAMED_TO_INTEGRATION.include?(name)
- end
-
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
attribute :type, Gitlab::Integrations::StiType.new
@@ -99,9 +80,9 @@ class Integration < ApplicationRecord
scope :by_active_flag, -> (flag) { where(active: flag) }
scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
scope :inherit, -> { where.not(inherit_from_id: nil) }
- scope :for_group, -> (group) { where(group_id: group, type: available_services_types(include_project_specific: false)) }
- scope :for_template, -> { where(template: true, type: available_services_types(include_project_specific: false)) }
- scope :for_instance, -> { where(instance: true, type: available_services_types(include_project_specific: false)) }
+ scope :for_group, -> (group) { where(group_id: group, type: available_integration_types(include_project_specific: false)) }
+ scope :for_template, -> { where(template: true, type: available_integration_types(include_project_specific: false)) }
+ scope :for_instance, -> { where(instance: true, type: available_integration_types(include_project_specific: false)) }
scope :push_hooks, -> { where(push_events: true, active: true) }
scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) }
@@ -156,9 +137,13 @@ class Integration < ApplicationRecord
args.each do |arg|
class_eval <<~RUBY, __FILE__, __LINE__ + 1
+ def #{arg}
+ Gitlab::Utils.to_boolean(properties['#{arg}'])
+ end
+
def #{arg}?
# '!!' is used because nil or empty string is converted to nil
- !!ActiveRecord::Type::Boolean.new.cast(#{arg})
+ !!#{arg}
end
RUBY
end
@@ -169,7 +154,7 @@ class Integration < ApplicationRecord
end
def self.event_names
- self.supported_events.map { |event| ServicesHelper.service_event_field_name(event) }
+ self.supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) }
end
def self.supported_event_actions
@@ -184,100 +169,92 @@ class Integration < ApplicationRecord
'push'
end
- def self.event_description(event)
- ServicesHelper.service_event_description(event)
- end
-
def self.find_or_create_templates
create_nonexistent_templates
for_template
end
def self.create_nonexistent_templates
- nonexistent_services = build_nonexistent_services_for(for_template)
- return if nonexistent_services.empty?
+ nonexistent_integrations = build_nonexistent_integrations_for(for_template)
+ return if nonexistent_integrations.empty?
# Create within a transaction to perform the lowest possible SQL queries.
transaction do
- nonexistent_services.each do |service|
- service.template = true
- service.save
+ nonexistent_integrations.each do |integration|
+ integration.template = true
+ integration.save
end
end
end
private_class_method :create_nonexistent_templates
def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil)
- return unless name.in?(available_services_names(include_project_specific: false))
+ return unless name.in?(available_integration_names(include_project_specific: false))
integration_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id)
end
def self.find_or_initialize_all_non_project_specific(scope)
- scope + build_nonexistent_services_for(scope)
+ scope + build_nonexistent_integrations_for(scope)
end
- def self.build_nonexistent_services_for(scope)
- nonexistent_services_types_for(scope).map do |service_type|
- integration_type_to_model(service_type).new
+ def self.build_nonexistent_integrations_for(scope)
+ nonexistent_integration_types_for(scope).map do |type|
+ integration_type_to_model(type).new
end
end
- private_class_method :build_nonexistent_services_for
+ private_class_method :build_nonexistent_integrations_for
- # Returns a list of service types that do not exist in the given scope.
+ # Returns a list of integration types that do not exist in the given scope.
# Example: ["AsanaService", ...]
- def self.nonexistent_services_types_for(scope)
+ def self.nonexistent_integration_types_for(scope)
# Using #map instead of #pluck to save one query count. This is because
# ActiveRecord loaded the object here, so we don't need to query again later.
- available_services_types(include_project_specific: false) - scope.map(&:type)
+ available_integration_types(include_project_specific: false) - scope.map(&:type)
end
- private_class_method :nonexistent_services_types_for
+ private_class_method :nonexistent_integration_types_for
- # Returns a list of available service names.
+ # Returns a list of available integration names.
# Example: ["asana", ...]
# @deprecated
- def self.available_services_names(include_project_specific: true, include_dev: true)
- service_names = services_names
- service_names += project_specific_services_names if include_project_specific
- service_names += dev_services_names if include_dev
+ def self.available_integration_names(include_project_specific: true, include_dev: true)
+ names = integration_names
+ names += project_specific_integration_names if include_project_specific
+ names += dev_integration_names if include_dev
- service_names.sort_by(&:downcase)
+ names.sort_by(&:downcase)
end
def self.integration_names
INTEGRATION_NAMES
end
- def self.services_names
- integration_names
- end
-
- def self.dev_services_names
+ def self.dev_integration_names
return [] unless Rails.env.development?
DEV_INTEGRATION_NAMES
end
- def self.project_specific_services_names
+ def self.project_specific_integration_names
PROJECT_SPECIFIC_INTEGRATION_NAMES
end
- # Returns a list of available service types.
+ # Returns a list of available integration types.
# Example: ["AsanaService", ...]
- def self.available_services_types(include_project_specific: true, include_dev: true)
- available_services_names(include_project_specific: include_project_specific, include_dev: include_dev).map do |service_name|
- integration_name_to_type(service_name)
+ def self.available_integration_types(include_project_specific: true, include_dev: true)
+ available_integration_names(include_project_specific: include_project_specific, include_dev: include_dev).map do
+ integration_name_to_type(_1)
end
end
- # Returns the model for the given service name.
+ # Returns the model for the given integration name.
# Example: "asana" => Integrations::Asana
def self.integration_name_to_model(name)
type = integration_name_to_type(name)
integration_type_to_model(type)
end
- # Returns the STI type for the given service name.
+ # Returns the STI type for the given integration name.
# Example: "asana" => "AsanaService"
def self.integration_name_to_type(name)
"#{name}_service".camelize
@@ -319,7 +296,7 @@ class Integration < ApplicationRecord
array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
where(type: type, group_id: group_ids, inherit_from_id: nil)
- .order(Arel.sql("array_position(#{array}::bigint[], services.group_id)"))
+ .order(Arel.sql("array_position(#{array}::bigint[], #{table_name}.group_id)"))
.first
end
private_class_method :closest_group_integration
@@ -337,7 +314,7 @@ class Integration < ApplicationRecord
with_templates ? active.where(template: true) : none,
active.where(instance: true),
active.where(group_id: group_ids, inherit_from_id: nil)
- ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records|
+ ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
build_from_integration(records.first, association => scope.id).save
end
end
@@ -406,7 +383,7 @@ class Integration < ApplicationRecord
%w[active]
end
- def to_service_hash
+ def to_integration_hash
as_json(methods: :type, except: %w[id template instance project_id group_id])
end
@@ -470,8 +447,8 @@ class Integration < ApplicationRecord
# Disable test for instance-level and group-level integrations.
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138
- def can_test?
- !(instance_level? || group_level?)
+ def testable?
+ project_level?
end
def project_level?
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index fef2774c593..590be52151c 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -18,14 +18,8 @@ module Integrations
attr_accessor :response
- after_save :compose_service_hook, if: :activated?
before_update :reset_password
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.save
- end
-
def reset_password
if bamboo_url_changed? && !password_touched?
self.password = nil
diff --git a/app/models/integrations/base_monitoring.rb b/app/models/integrations/base_monitoring.rb
new file mode 100644
index 00000000000..280eeda7c6c
--- /dev/null
+++ b/app/models/integrations/base_monitoring.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Base class for monitoring services
+#
+# These services integrate with a deployment solution like Prometheus
+# to provide additional features for environments.
+module Integrations
+ class BaseMonitoring < Integration
+ default_value_for :category, 'monitoring'
+
+ def self.supported_events
+ %w()
+ end
+
+ def can_query?
+ raise NotImplementedError
+ end
+
+ def query(_, *_)
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/models/integrations/base_slash_commands.rb b/app/models/integrations/base_slash_commands.rb
index eacf1184aae..1d271e75a91 100644
--- a/app/models/integrations/base_slash_commands.rb
+++ b/app/models/integrations/base_slash_commands.rb
@@ -20,7 +20,7 @@ module Integrations
%w()
end
- def can_test?
+ def testable?
false
end
diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb
index 906a5d02f9c..94a37f0c4f2 100644
--- a/app/models/integrations/buildkite.rb
+++ b/app/models/integrations/buildkite.rb
@@ -4,7 +4,9 @@ require "addressable/uri"
module Integrations
class Buildkite < BaseCi
+ include HasWebHook
include ReactiveService
+ extend Gitlab::Utils::Override
ENDPOINT = "https://buildkite.com"
@@ -13,8 +15,6 @@ module Integrations
validates :project_url, presence: true, public_url: true, if: :activated?
validates :token, presence: true, if: :activated?
- after_save :compose_service_hook, if: :activated?
-
def self.supported_events
%w(push merge_request tag_push)
end
@@ -35,21 +35,15 @@ module Integrations
self.properties.delete('enable_ssl_verification') # Remove unused key
end
- def webhook_url
+ override :hook_url
+ def hook_url
"#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}"
end
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.url = webhook_url
- hook.enable_ssl_verification = true
- hook.save
- end
-
def execute(data)
return unless supported_events.include?(data[:object_kind])
- service_hook.execute(data)
+ execute_web_hook!(data)
end
def commit_status(sha, ref)
@@ -76,18 +70,22 @@ module Integrations
'buildkite'
end
+ def help
+ s_('ProjectService|Run CI/CD pipelines with Buildkite.')
+ end
+
def fields
[
{ type: 'text',
name: 'token',
- title: 'Integration Token',
- help: 'This token will be provided when you create a Buildkite pipeline with a GitLab repository',
+ title: _('Token'),
+ help: s_('ProjectService|The token you get after you create a Buildkite pipeline with a GitLab repository.'),
required: true },
{ type: 'text',
name: 'project_url',
- title: 'Pipeline URL',
- placeholder: "#{ENDPOINT}/acme-inc/test-pipeline",
+ title: _('Pipeline URL'),
+ placeholder: "#{ENDPOINT}/example-org/test-pipeline",
required: true }
]
end
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index eede3d00307..c78fc6eff51 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -2,6 +2,8 @@
module Integrations
class Campfire < Integration
+ include ActionView::Helpers::UrlHelper
+
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -13,15 +15,39 @@ module Integrations
'Send notifications about push events to Campfire chat rooms.'
end
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
+ s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
+ end
+
def self.to_param
'campfire'
end
def fields
[
- { type: 'text', name: 'token', placeholder: '', required: true },
- { type: 'text', name: 'subdomain', placeholder: '' },
- { type: 'text', name: 'room', placeholder: '' }
+ {
+ type: 'text',
+ name: 'token',
+ title: _('Campfire token'),
+ placeholder: '',
+ help: s_('CampfireService|API authentication token from Campfire.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'subdomain',
+ title: _('Campfire subdomain (optional)'),
+ placeholder: '',
+ help: s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ },
+ {
+ type: 'text',
+ name: 'room',
+ title: _('Campfire room ID (optional)'),
+ placeholder: '123456',
+ help: s_('CampfireService|From the end of the room URL.')
+ }
]
end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index 30f73496993..7f111f482dd 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -58,7 +58,7 @@ module Integrations
]
end
- def can_test?
+ def testable?
false
end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index dd4b0664d52..27c2fcf266b 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -2,10 +2,13 @@
module Integrations
class Datadog < Integration
- DEFAULT_SITE = 'datadoghq.com'
- URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/'
- URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api'
- URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/"
+ include HasWebHook
+ extend Gitlab::Utils::Override
+
+ DEFAULT_DOMAIN = 'datadoghq.com'
+ URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_domain}/api/v2/webhook'
+ URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_domain}/account/settings#api'
+ URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_DOMAIN}/account_management/api-app-keys/"
SUPPORTED_EVENTS = %w[
pipeline job
@@ -21,12 +24,10 @@ module Integrations
validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? }
end
- after_save :compose_service_hook, if: :activated?
-
def initialize_properties
super
- self.datadog_site ||= DEFAULT_SITE
+ self.datadog_site ||= DEFAULT_DOMAIN
end
def self.supported_events
@@ -62,7 +63,7 @@ module Integrations
{
type: 'text',
name: 'datadog_site',
- placeholder: DEFAULT_SITE,
+ placeholder: DEFAULT_DOMAIN,
help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site',
required: false
},
@@ -98,35 +99,31 @@ module Integrations
]
end
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.url = hook_url
- hook.save
- end
-
+ override :hook_url
def hook_url
- url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site)
+ url = api_url.presence || sprintf(URL_TEMPLATE, datadog_domain: datadog_domain)
url = URI.parse(url)
- url.path = File.join(url.path || '/', api_key)
- query = { service: datadog_service.presence, env: datadog_env.presence }.compact
- url.query = query.to_query unless query.empty?
+ query = {
+ "dd-api-key" => api_key,
+ service: datadog_service.presence,
+ env: datadog_env.presence
+ }.compact
+ url.query = query.to_query
url.to_s
end
def api_keys_url
return URL_API_KEYS_DOCS unless datadog_site.presence
- sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site)
+ sprintf(URL_TEMPLATE_API_KEYS, datadog_domain: datadog_domain)
end
def execute(data)
- return if project.disabled_services.include?(to_param)
-
object_kind = data[:object_kind]
object_kind = 'job' if object_kind == 'build'
return unless supported_events.include?(object_kind)
- service_hook.execute(data, "#{object_kind} hook")
+ execute_web_hook!(data, "#{object_kind} hook")
end
def test(data)
@@ -139,5 +136,14 @@ module Integrations
{ success: true, result: result[:message] }
end
+
+ private
+
+ def datadog_domain
+ # Transparently ignore "app" prefix from datadog_site as the official docs table in
+ # https://docs.datadoghq.com/getting_started/site/ is confusing for internal URLs.
+ # US3 needs to keep a prefix but other datacenters cannot have the listed "app" prefix
+ datadog_site.delete_prefix("app.")
+ end
end
end
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index ef6d46fd3d3..76160a61bc3 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -54,6 +54,8 @@ module Integrations
builder.add_embed do |embed|
embed.author = Discordrb::Webhooks::EmbedAuthor.new(name: message.user_name, icon_url: message.user_avatar)
embed.description = (message.pretext + "\n" + Array.wrap(message.attachments).join("\n")).gsub(ATTACHMENT_REGEX, " \\k<entry> - \\k<name>\n")
+ embed.colour = 16543014 # The hex "fc6d26" as an Integer
+ embed.timestamp = Time.now.utc
end
end
rescue RestClient::Exception => error
diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb
index 0f021356815..c93ae432fe9 100644
--- a/app/models/integrations/drone_ci.rb
+++ b/app/models/integrations/drone_ci.rb
@@ -2,8 +2,10 @@
module Integrations
class DroneCi < BaseCi
+ include HasWebHook
include ReactiveService
include ServicePushDataValidations
+ extend Gitlab::Utils::Override
prop_accessor :drone_url, :token
boolean_accessor :enable_ssl_verification
@@ -11,24 +13,16 @@ module Integrations
validates :drone_url, presence: true, public_url: true, if: :activated?
validates :token, presence: true, if: :activated?
- after_save :compose_service_hook, if: :activated?
-
- def compose_service_hook
- hook = service_hook || build_service_hook
- # If using a service template, project may not be available
- hook.url = [drone_url, "/api/hook", "?owner=#{project.namespace.full_path}", "&name=#{project.path}", "&access_token=#{token}"].join if project
- hook.enable_ssl_verification = !!enable_ssl_verification
- hook.save
- end
-
def execute(data)
+ return unless project
+
case data[:object_kind]
when 'push'
- service_hook.execute(data) if push_valid?(data)
+ execute_web_hook!(data) if push_valid?(data)
when 'merge_request'
- service_hook.execute(data) if merge_request_valid?(data)
+ execute_web_hook!(data) if merge_request_valid?(data)
when 'tag_push'
- service_hook.execute(data) if tag_push_valid?(data)
+ execute_web_hook!(data) if tag_push_valid?(data)
end
end
@@ -105,5 +99,21 @@ module Integrations
{ type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" }
]
end
+
+ override :hook_url
+ def hook_url
+ [drone_url, "/hook", "?owner=#{project.namespace.full_path}", "&name=#{project.path}", "&access_token=#{token}"].join
+ end
+
+ override :hook_ssl_verification
+ def hook_ssl_verification
+ !!enable_ssl_verification
+ end
+
+ override :update_web_hook!
+ def update_web_hook!
+ # If using a service template, project may not be available
+ super if project
+ end
end
end
diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb
index 0a4e8d92ed7..24d343b7cb4 100644
--- a/app/models/integrations/ewm.rb
+++ b/app/models/integrations/ewm.rb
@@ -27,7 +27,7 @@ module Integrations
'ewm'
end
- def can_test?
+ def testable?
false
end
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index 815e86bcaa1..55fc60990f3 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -2,7 +2,9 @@
module Integrations
class Jenkins < BaseCi
+ include HasWebHook
include ActionView::Helpers::UrlHelper
+ extend Gitlab::Utils::Override
prop_accessor :jenkins_url, :project_name, :username, :password
@@ -16,8 +18,6 @@ module Integrations
default_value_for :merge_requests_events, false
default_value_for :tag_push_events, false
- after_save :compose_service_hook, if: :activated?
-
def reset_password
# don't reset the password if a new one is provided
if (jenkins_url_changed? || username.blank?) && !password_touched?
@@ -25,16 +25,10 @@ module Integrations
end
end
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.url = hook_url
- hook.save
- end
-
def execute(data)
return unless supported_events.include?(data[:object_kind])
- service_hook.execute(data, "#{data[:object_kind]}_hook")
+ execute_web_hook!(data, "#{data[:object_kind]}_hook")
end
def test(data)
@@ -48,6 +42,7 @@ module Integrations
{ success: true, result: result[:message] }
end
+ override :hook_url
def hook_url
url = URI.parse(jenkins_url)
url.path = File.join(url.path || '/', "project/#{project_name}")
@@ -97,7 +92,6 @@ module Integrations
{
type: 'text',
name: 'username',
- required: true,
help: s_('The username for the Jenkins server.')
},
{
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index a5aee35bada..1dc5c0db9e3 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -272,6 +272,10 @@ module Integrations
test(nil)[:success]
end
+ def configured?
+ active? && valid_connection?
+ end
+
def test(_)
result = server_info
success = result.present?
@@ -533,7 +537,7 @@ module Integrations
def update_deployment_type?
(api_url_changed? || url_changed? || username_changed? || password_changed?) &&
- can_test?
+ testable?
end
def update_deployment_type
@@ -573,15 +577,6 @@ module Integrations
data_fields.deployment_server!
end
end
-
- def self.event_description(event)
- case event
- when "merge_request", "merge_request_events"
- s_("JiraService|Jira comments are created when an issue is referenced in a merge request.")
- when "commit", "commit_events"
- s_("JiraService|Jira comments are created when an issue is referenced in a commit.")
- end
- end
end
end
diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb
index 6cd664da9e7..30a8ba973c1 100644
--- a/app/models/integrations/mattermost_slash_commands.rb
+++ b/app/models/integrations/mattermost_slash_commands.rb
@@ -6,7 +6,7 @@ module Integrations
prop_accessor :token
- def can_test?
+ def testable?
false
end
diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb
index a0eae9e4abf..7359be83d4f 100644
--- a/app/models/integrations/mock_ci.rb
+++ b/app/models/integrations/mock_ci.rb
@@ -83,7 +83,7 @@ module Integrations
end
end
- def can_test?
+ def testable?
false
end
end
diff --git a/app/models/integrations/mock_monitoring.rb b/app/models/integrations/mock_monitoring.rb
new file mode 100644
index 00000000000..72bb292edaa
--- /dev/null
+++ b/app/models/integrations/mock_monitoring.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Integrations
+ class MockMonitoring < BaseMonitoring
+ def title
+ 'Mock monitoring'
+ end
+
+ def description
+ 'Mock monitoring service'
+ end
+
+ def self.to_param
+ 'mock_monitoring'
+ end
+
+ def metrics(environment)
+ Gitlab::Json.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
+ end
+
+ def testable?
+ false
+ end
+ end
+end
diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb
index b597bd11175..fb0917db02b 100644
--- a/app/models/integrations/packagist.rb
+++ b/app/models/integrations/packagist.rb
@@ -2,6 +2,9 @@
module Integrations
class Packagist < Integration
+ include HasWebHook
+ extend Gitlab::Utils::Override
+
prop_accessor :username, :token, :server
validates :username, presence: true, if: :activated?
@@ -10,8 +13,6 @@ module Integrations
default_value_for :push_events, true
default_value_for :tag_push_events, true
- after_save :compose_service_hook, if: :activated?
-
def title
'Packagist'
end
@@ -39,7 +40,7 @@ module Integrations
def execute(data)
return unless supported_events.include?(data[:object_kind])
- service_hook.execute(data)
+ execute_web_hook!(data)
end
def test(data)
@@ -53,12 +54,7 @@ module Integrations
{ success: true, result: result[:message] }
end
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.url = hook_url
- hook.save
- end
-
+ override :hook_url
def hook_url
base_url = server.presence || 'https://packagist.org'
"#{base_url}/api/update-package?username=#{username}&apiToken=#{token}"
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index 585bc14242a..efba35cc2a8 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -57,7 +57,7 @@ module Integrations
PipelineNotificationWorker.new.perform(pipeline_id, recipients: all_recipients)
end
- def can_test?
+ def testable?
project&.ci_pipelines&.any?
end
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index 46f97cc3c6b..24cfd51eb55 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -2,17 +2,23 @@
module Integrations
class Pivotaltracker < Integration
+ include ActionView::Helpers::UrlHelper
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'
prop_accessor :token, :restrict_to_branch
validates :token, presence: true, if: :activated?
def title
- 'PivotalTracker'
+ 'Pivotal Tracker'
end
def description
- s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.')
+ s_('PivotalTrackerService|Add commit messages as comments to Pivotal Tracker stories.')
+ end
+
+ def help
+ docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
+ s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def self.to_param
@@ -24,14 +30,15 @@ module Integrations
{
type: 'text',
name: 'token',
- placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'),
+ help: s_('PivotalTrackerService|Pivotal Tracker API token. User must have access to the story. All comments are attributed to this user.'),
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \
- 'automatically inspected. Leave blank to include all branches.')
+ title: 'Restrict to branch (optional)',
+ help: s_('PivotalTrackerService|Comma-separated list of branches to ' \
+ 'automatically inspect. Leave blank to include all branches.')
}
]
end
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
new file mode 100644
index 00000000000..54cb823d606
--- /dev/null
+++ b/app/models/integrations/prometheus.rb
@@ -0,0 +1,203 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Prometheus < BaseMonitoring
+ include PrometheusAdapter
+
+ # Access to prometheus is directly through the API
+ prop_accessor :api_url
+ prop_accessor :google_iap_service_account_json
+ prop_accessor :google_iap_audience_client_id
+ boolean_accessor :manual_configuration
+
+ # We need to allow the self-monitoring project to connect to the internal
+ # Prometheus instance.
+ # Since the internal Prometheus instance is usually a localhost URL, we need
+ # to allow localhost URLs when the following conditions are true:
+ # 1. project is the self-monitoring project.
+ # 2. api_url is the internal Prometheus URL.
+ with_options presence: true do
+ validates :api_url, public_url: true, if: ->(object) { object.manual_configuration? && !object.allow_local_api_url? }
+ validates :api_url, url: true, if: ->(object) { object.manual_configuration? && object.allow_local_api_url? }
+ end
+
+ before_save :synchronize_service_state
+
+ after_save :clear_reactive_cache!
+
+ after_commit :track_events
+
+ after_create_commit :create_default_alerts
+
+ scope :preload_project, -> { preload(:project) }
+ scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) }
+
+ def initialize_properties
+ if properties.nil?
+ self.properties = {}
+ end
+ end
+
+ def show_active_box?
+ false
+ end
+
+ def title
+ 'Prometheus'
+ end
+
+ def description
+ s_('PrometheusService|Monitor application health with Prometheus metrics and dashboards')
+ end
+
+ def self.to_param
+ 'prometheus'
+ end
+
+ def fields
+ [
+ {
+ type: 'checkbox',
+ name: 'manual_configuration',
+ title: s_('PrometheusService|Active'),
+ help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'api_url',
+ title: 'API URL',
+ placeholder: s_('PrometheusService|https://prometheus.example.com/'),
+ help: s_('PrometheusService|The Prometheus API base URL.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'google_iap_audience_client_id',
+ title: 'Google IAP Audience Client ID',
+ placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
+ help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
+ autocomplete: 'off',
+ required: false
+ },
+ {
+ type: 'textarea',
+ name: 'google_iap_service_account_json',
+ title: 'Google IAP Service Account JSON',
+ placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
+ help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
+ required: false
+ }
+ ]
+ end
+
+ # Check we can connect to the Prometheus API
+ def test(*args)
+ prometheus_client.ping
+ { success: true, result: 'Checked API endpoint' }
+ rescue Gitlab::PrometheusClient::Error => err
+ { success: false, result: err }
+ end
+
+ def prometheus_client
+ return unless should_return_client?
+
+ options = prometheus_client_default_options.merge(
+ allow_local_requests: allow_local_api_url?
+ )
+
+ if behind_iap?
+ # Adds the Authorization header
+ options[:headers] = iap_client.apply({})
+ end
+
+ Gitlab::PrometheusClient.new(api_url, options)
+ end
+
+ def prometheus_available?
+ return false if template?
+ return false unless project
+
+ project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster|
+ cluster.integration_prometheus_available?
+ end
+ end
+
+ def allow_local_api_url?
+ allow_local_requests_from_web_hooks_and_services? ||
+ (self_monitoring_project? && internal_prometheus_url?)
+ end
+
+ def configured?
+ should_return_client?
+ end
+
+ private
+
+ delegate :allow_local_requests_from_web_hooks_and_services?, to: :current_settings, private: true
+
+ def self_monitoring_project?
+ project && project.id == current_settings.self_monitoring_project_id
+ end
+
+ def internal_prometheus_url?
+ api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri
+ end
+
+ def should_return_client?
+ api_url.present? && manual_configuration? && active? && valid?
+ end
+
+ def current_settings
+ Gitlab::CurrentSettings.current_application_settings
+ end
+
+ def synchronize_service_state
+ self.active = prometheus_available? || manual_configuration?
+
+ true
+ end
+
+ def track_events
+ if enabled_manual_prometheus?
+ Gitlab::Tracking.event('cluster:services:prometheus', 'enabled_manual_prometheus')
+ elsif disabled_manual_prometheus?
+ Gitlab::Tracking.event('cluster:services:prometheus', 'disabled_manual_prometheus')
+ end
+
+ true
+ end
+
+ def enabled_manual_prometheus?
+ manual_configuration_changed? && manual_configuration?
+ end
+
+ def disabled_manual_prometheus?
+ manual_configuration_changed? && !manual_configuration?
+ end
+
+ def create_default_alerts
+ return unless project_id
+
+ ::Prometheus::CreateDefaultAlertsWorker.perform_async(project_id)
+ end
+
+ def behind_iap?
+ manual_configuration? && google_iap_audience_client_id.present? && google_iap_service_account_json.present?
+ end
+
+ def clean_google_iap_service_account
+ return unless google_iap_service_account_json
+
+ google_iap_service_account_json
+ .then { |json| Gitlab::Json.parse(json) }
+ .except('token_credential_uri')
+ end
+
+ def iap_client
+ @iap_client ||= Google::Auth::Credentials
+ .new(clean_google_iap_service_account, target_audience: google_iap_audience_client_id)
+ .client
+ end
+ end
+end
diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb
index 3f14c5d82b3..135c304b57e 100644
--- a/app/models/integrations/teamcity.rb
+++ b/app/models/integrations/teamcity.rb
@@ -18,7 +18,6 @@ module Integrations
attr_accessor :response
- after_save :compose_service_hook, if: :activated?
before_update :reset_password
class << self
@@ -29,20 +28,6 @@ module Integrations
def supported_events
%w(push merge_request)
end
-
- def event_description(event)
- case event
- when 'push', 'push_events'
- 'TeamCity CI will be triggered after every push to the repository except branch delete'
- when 'merge_request', 'merge_request_events'
- 'TeamCity CI will be triggered after a merge request has been created or updated'
- end
- end
- end
-
- def compose_service_hook
- hook = service_hook || build_service_hook
- hook.save
end
def reset_password
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index b56bac58705..f114094d69c 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -16,7 +16,7 @@
# * Add `usage` value to enum
# * (Optionally) add columns to `internal_ids` if needed for scope.
class InternalId < ApplicationRecord
- include Gitlab::Utils::StrongMemoize
+ extend Gitlab::Utils::StrongMemoize
belongs_to :project
belongs_to :namespace
@@ -25,6 +25,10 @@ class InternalId < ApplicationRecord
validates :usage, presence: true
+ scope :filter_by, -> (scope, usage) do
+ where(**scope, usage: usage)
+ end
+
# Increments #last_value and saves the record
#
# The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
@@ -53,18 +57,15 @@ class InternalId < ApplicationRecord
class << self
def track_greatest(subject, scope, usage, new_value, init)
- InternalIdGenerator.new(subject, scope, usage, init)
- .track_greatest(new_value)
+ build_generator(subject, scope, usage, init).track_greatest(new_value)
end
def generate_next(subject, scope, usage, init)
- InternalIdGenerator.new(subject, scope, usage, init)
- .generate
+ build_generator(subject, scope, usage, init).generate
end
def reset(subject, scope, usage, value)
- InternalIdGenerator.new(subject, scope, usage)
- .reset(value)
+ build_generator(subject, scope, usage).reset(value)
end
# Flushing records is generally safe in a sense that those
@@ -77,11 +78,36 @@ class InternalId < ApplicationRecord
where(filter).delete_all
end
+
+ def internal_id_transactions_increment(operation:, usage:)
+ self.internal_id_transactions_total.increment(
+ operation: operation,
+ usage: usage.to_s,
+ in_transaction: ActiveRecord::Base.connection.transaction_open?.to_s
+ )
+ end
+
+ def internal_id_transactions_total
+ strong_memoize(:internal_id_transactions_total) do
+ name = :gitlab_internal_id_transactions_total
+ comment = 'Counts all the internal ids happening within transaction'
+
+ Gitlab::Metrics.counter(name, comment)
+ end
+ end
+
+ private
+
+ def build_generator(subject, scope, usage, init = nil)
+ if Feature.enabled?(:generate_iids_without_explicit_locking)
+ ImplicitlyLockingInternalIdGenerator.new(subject, scope, usage, init)
+ else
+ InternalIdGenerator.new(subject, scope, usage, init)
+ end
+ end
end
class InternalIdGenerator
- extend Gitlab::Utils::StrongMemoize
-
# Generate next internal id for a given scope and usage.
#
# For currently supported usages, see #usage enum.
@@ -117,7 +143,7 @@ class InternalId < ApplicationRecord
# init: Block that gets called to initialize InternalId record if not present
# Make sure to not throw exceptions in the absence of records (if this is expected).
def generate
- self.class.internal_id_transactions_increment(operation: :generate, usage: usage)
+ InternalId.internal_id_transactions_increment(operation: :generate, usage: usage)
subject.transaction do
# Create a record in internal_ids if one does not yet exist
@@ -134,7 +160,7 @@ class InternalId < ApplicationRecord
def reset(value)
return false unless value
- self.class.internal_id_transactions_increment(operation: :reset, usage: usage)
+ InternalId.internal_id_transactions_increment(operation: :reset, usage: usage)
updated =
InternalId
@@ -149,8 +175,9 @@ class InternalId < ApplicationRecord
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
+
def track_greatest(new_value)
- self.class.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
+ InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
subject.transaction do
record.track_greatest_and_save!(new_value)
@@ -162,7 +189,7 @@ class InternalId < ApplicationRecord
end
def with_lock(&block)
- self.class.internal_id_transactions_increment(operation: :with_lock, usage: usage)
+ InternalId.internal_id_transactions_increment(operation: :with_lock, usage: usage)
record.with_lock(&block)
end
@@ -199,22 +226,118 @@ class InternalId < ApplicationRecord
rescue ActiveRecord::RecordNotUnique
lookup
end
+ end
- def self.internal_id_transactions_increment(operation:, usage:)
- self.internal_id_transactions_total.increment(
- operation: operation,
- usage: usage.to_s,
- in_transaction: ActiveRecord::Base.connection.transaction_open?.to_s
- )
+ class ImplicitlyLockingInternalIdGenerator
+ # Generate next internal id for a given scope and usage.
+ #
+ # For currently supported usages, see #usage enum.
+ #
+ # The method implements a locking scheme that has the following properties:
+ # 1) Generated sequence of internal ids is unique per (scope and usage)
+ # 2) The method is thread-safe and may be used in concurrent threads/processes.
+ # 3) The generated sequence is gapless.
+ # 4) In the absence of a record in the internal_ids table, one will be created
+ # and last_value will be calculated on the fly.
+ #
+ # subject: The instance or class we're generating an internal id for.
+ # scope: Attributes that define the scope for id generation.
+ # Valid keys are `project/project_id` and `namespace/namespace_id`.
+ # usage: Symbol to define the usage of the internal id, see InternalId.usages
+ # init: Proc that accepts the subject and the scope and returns Integer|NilClass
+ attr_reader :subject, :scope, :scope_attrs, :usage, :init
+
+ def initialize(subject, scope, usage, init = nil)
+ @subject = subject
+ @scope = scope
+ @usage = usage
+ @init = init
+
+ raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
+
+ unless InternalId.usages.has_key?(usage.to_s)
+ raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages"
+ end
end
- def self.internal_id_transactions_total
- strong_memoize(:internal_id_transactions_total) do
- name = :gitlab_internal_id_transactions_total
- comment = 'Counts all the internal ids happening within transaction'
+ # Generates next internal id and returns it
+ # init: Block that gets called to initialize InternalId record if not present
+ # Make sure to not throw exceptions in the absence of records (if this is expected).
+ def generate
+ InternalId.internal_id_transactions_increment(operation: :generate, usage: usage)
- Gitlab::Metrics.counter(name, comment)
+ next_iid = update_record!(subject, scope, usage, arel_table[:last_value] + 1)
+
+ return next_iid if next_iid
+
+ create_record!(subject, scope, usage, init) do |iid|
+ iid.last_value += 1
end
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
+
+ # Reset tries to rewind to `value-1`. This will only succeed,
+ # if `value` stored in database is equal to `last_value`.
+ # value: The expected last_value to decrement
+ def reset(value)
+ return false unless value
+
+ InternalId.internal_id_transactions_increment(operation: :reset, usage: usage)
+
+ iid = update_record!(subject, scope.merge(last_value: value), usage, arel_table[:last_value] - 1)
+ iid == value - 1
+ end
+
+ # Create a record in internal_ids if one does not yet exist
+ # and set its new_value if it is higher than the current last_value
+ def track_greatest(new_value)
+ InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
+
+ function = Arel::Nodes::NamedFunction.new('GREATEST', [
+ arel_table[:last_value],
+ new_value.to_i
+ ])
+
+ next_iid = update_record!(subject, scope, usage, function)
+ return next_iid if next_iid
+
+ create_record!(subject, scope, usage, init) do |object|
+ object.last_value = [object.last_value, new_value].max
+ end
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
+
+ private
+
+ def update_record!(subject, scope, usage, new_value)
+ stmt = Arel::UpdateManager.new
+ stmt.table(arel_table)
+ stmt.set(arel_table[:last_value] => new_value)
+ stmt.wheres = InternalId.filter_by(scope, usage).arel.constraints
+
+ ActiveRecord::Base.connection.insert(stmt, 'Update InternalId', 'last_value')
+ end
+
+ def create_record!(subject, scope, usage, init)
+ raise ArgumentError, 'Cannot initialize without init!' unless init
+
+ instance = subject.is_a?(::Class) ? nil : subject
+
+ subject.transaction(requires_new: true) do
+ last_value = init.call(instance, scope) || 0
+
+ internal_id = InternalId.create!(**scope, usage: usage, last_value: last_value) do |subject|
+ yield subject if block_given?
+ end
+
+ internal_id.last_value
+ end
+ end
+
+ def arel_table
+ InternalId.arel_table
end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 48f388ea48d..00fcba5298a 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -80,6 +80,7 @@ class Issue < ApplicationRecord
has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany
has_many :prometheus_alerts, through: :prometheus_alert_events
+ accepts_nested_attributes_for :issuable_severity, update_only: true
accepts_nested_attributes_for :sentry_issue
validates :project, presence: true
@@ -195,11 +196,23 @@ class Issue < ApplicationRecord
end
end
- # Alias to state machine .with_state_id method
- # This needs to be defined after the state machine block to avoid errors
class << self
+ extend ::Gitlab::Utils::Override
+
+ # Alias to state machine .with_state_id method
+ # This needs to be defined after the state machine block to avoid errors
alias_method :with_state, :with_state_id
alias_method :with_states, :with_state_ids
+
+ override :order_upvotes_desc
+ def order_upvotes_desc
+ reorder(upvotes_count: :desc)
+ end
+
+ override :order_upvotes_asc
+ def order_upvotes_asc
+ reorder(upvotes_count: :asc)
+ end
end
def self.relative_positioning_query_base(issue)
@@ -267,10 +280,41 @@ class Issue < ApplicationRecord
# `with_cte` argument allows sorting when using CTE queries and prevents
# errors in postgres when using CTE search optimisation
def self.order_by_position_and_priority(with_cte: false)
+ order = Gitlab::Pagination::Keyset::Order.build([column_order_relative_position, column_order_highest_priority, column_order_id_desc])
+
order_labels_priority(with_cte: with_cte)
- .reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'),
- Gitlab::Database.nulls_last_order('highest_priority', 'ASC'),
- "id DESC")
+ .reorder(order)
+ end
+
+ def self.column_order_relative_position
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'relative_position',
+ column_expression: arel_table[:relative_position],
+ order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'ASC'),
+ reversed_order_expression: Gitlab::Database.nulls_last_order('issues.relative_position', 'DESC'),
+ order_direction: :asc,
+ nullable: :nulls_last,
+ distinct: false
+ )
+ end
+
+ def self.column_order_highest_priority
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'highest_priority',
+ column_expression: Arel.sql('highest_priorities.label_priority'),
+ order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'ASC'),
+ reversed_order_expression: Gitlab::Database.nulls_last_order('highest_priorities.label_priority', 'DESC'),
+ order_direction: :asc,
+ nullable: :nulls_last,
+ distinct: false
+ )
+ end
+
+ def self.column_order_id_desc
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table[:id].desc
+ )
end
# Temporary disable moving null elements because of performance problems
@@ -394,8 +438,15 @@ class Issue < ApplicationRecord
end
def check_for_spam?
- publicly_visible? &&
- (title_changed? || description_changed? || confidential_changed?)
+ # content created via support bots is always checked for spam, EVEN if
+ # the issue is not publicly visible and/or confidential
+ return true if author.support_bot? && spammable_attribute_changed?
+
+ # Only check for spam on issues which are publicly visible (and thus indexed in search engines)
+ return false unless publicly_visible?
+
+ # Only check for spam if certain attributes have changed
+ spammable_attribute_changed?
end
def as_json(options = {})
@@ -481,8 +532,21 @@ class Issue < ApplicationRecord
issue_assignees.pluck(:user_id)
end
+ def update_upvotes_count
+ self.lock!
+ self.update_column(:upvotes_count, self.upvotes)
+ end
+
private
+ def spammable_attribute_changed?
+ title_changed? ||
+ description_changed? ||
+ # NOTE: We need to check them for spam when issues are made non-confidential, because spam
+ # may have been added while they were confidential and thus not being checked for spam.
+ confidential_changed?(from: true, to: false)
+ end
+
# Ensure that the metrics association is safely created and respecting the unique constraint on issue_id
override :ensure_metrics
def ensure_metrics
diff --git a/app/models/member.rb b/app/models/member.rb
index 0636c3c2d4e..14c886e3ab8 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -156,7 +156,7 @@ class Member < ApplicationRecord
distinct_members = select('DISTINCT ON (user_id, invite_email) *')
.order('user_id, invite_email, access_level DESC, expires_at DESC, created_at ASC')
- from(distinct_members, :members)
+ unscoped.from(distinct_members, :members)
end
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
@@ -232,140 +232,9 @@ class Member < ApplicationRecord
find_by(invite_token: invite_token)
end
- def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil, ldap: false)
- # rubocop: disable CodeReuse/ServiceClass
- # `user` can be either a User object, User ID or an email to be invited
- member = retrieve_member(source, user, existing_members)
- access_level = retrieve_access_level(access_level)
-
- return member unless can_update_member?(current_user, member)
-
- set_member_attributes(
- member,
- access_level,
- current_user: current_user,
- expires_at: expires_at,
- ldap: ldap
- )
-
- if member.request?
- ::Members::ApproveAccessRequestService.new(
- current_user,
- access_level: access_level
- ).execute(
- member,
- skip_authorization: ldap,
- skip_log_audit_event: ldap
- )
- else
- member.save
- end
-
- member
- # rubocop: enable CodeReuse/ServiceClass
- end
-
- # Populates the attributes of a member.
- #
- # This logic resides in a separate method so that EE can extend this logic,
- # without having to patch the `add_user` method directly.
- def set_member_attributes(member, access_level, current_user: nil, expires_at: nil, ldap: false)
- member.attributes = {
- created_by: member.created_by || current_user,
- access_level: access_level,
- expires_at: expires_at
- }
- end
-
- def add_users(source, users, access_level, current_user: nil, expires_at: nil)
- return [] unless users.present?
-
- emails, users, existing_members = parse_users_list(source, users)
-
- self.transaction do
- (emails + users).map! do |user|
- add_user(
- source,
- user,
- access_level,
- existing_members: existing_members,
- current_user: current_user,
- expires_at: expires_at
- )
- end
- end
- end
-
- def access_levels
- Gitlab::Access.sym_options
- end
-
def valid_email?(email)
Devise.email_regexp.match?(email)
end
-
- private
-
- def parse_users_list(source, list)
- emails = []
- user_ids = []
- users = []
- existing_members = {}
-
- list.each do |item|
- case item
- when User
- users << item
- when Integer
- user_ids << item
- when /\A\d+\Z/
- user_ids << item.to_i
- when Devise.email_regexp
- emails << item
- end
- end
-
- if user_ids.present?
- users.concat(User.where(id: user_ids))
- # the below will automatically discard invalid user_ids
- existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
- end
-
- [emails, users, existing_members]
- end
-
- # This method is used to find users that have been entered into the "Add members" field.
- # These can be the User objects directly, their IDs, their emails, or new emails to be invited.
- def retrieve_user(user)
- return user if user.is_a?(User)
-
- return User.find_by(id: user) if user.is_a?(Integer)
-
- User.find_by_any_email(user) || user
- end
-
- def retrieve_member(source, user, existing_members)
- user = retrieve_user(user)
-
- if user.is_a?(User)
- if existing_members
- existing_members[user.id] || source.members.build(user_id: user.id)
- else
- source.members_and_requesters.find_or_initialize_by(user_id: user.id)
- end
- else
- source.members.build(invite_email: user)
- end
- end
-
- def retrieve_access_level(access_level)
- access_levels.fetch(access_level) { access_level.to_i }
- end
-
- def can_update_member?(current_user, member)
- # There is no current user for bulk actions, in which case anything is allowed
- !current_user || current_user.can?(:"update_#{member.type.underscore}", member)
- end
end
def real_source_type
@@ -570,7 +439,7 @@ class Member < ApplicationRecord
def update_highest_role?
return unless user_id.present?
- previous_changes[:access_level].present?
+ previous_changes[:access_level].present? || destroyed?
end
def update_highest_role_attribute
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index c7bc31cde5d..cf5906a4cbf 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -28,14 +28,12 @@ class GroupMember < Member
attr_accessor :last_owner, :last_blocked_owner
+ self.enumerate_columns_in_select_statements = true
+
def self.access_level_roles
Gitlab::Access.options_with_owner
end
- def self.access_levels
- Gitlab::Access.sym_options_with_owner
- end
-
def self.pluck_user_ids
pluck(:user_id)
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 41ecc4cbf01..5040879e177 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -48,7 +48,7 @@ class ProjectMember < Member
project_ids.each do |project_id|
project = Project.find(project_id)
- add_users(
+ Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
project,
users,
access_level,
@@ -80,12 +80,6 @@ class ProjectMember < Member
def access_level_roles
Gitlab::Access.options
end
-
- private
-
- def can_update_member?(current_user, member)
- super || (member.owner? && member.new_record?)
- end
end
def project
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 68fb957759d..7ca83d1d68c 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -300,6 +300,11 @@ class MergeRequest < ApplicationRecord
query = joins(:metrics)
+ if !target_project_id && self.where_values_hash["target_project_id"]
+ target_project_id = self.where_values_hash["target_project_id"]
+ query = query.unscope(where: :target_project_id)
+ end
+
project_condition = if target_project_id
MergeRequest::Metrics.arel_table[:target_project_id].eq(target_project_id)
else
@@ -360,7 +365,7 @@ class MergeRequest < ApplicationRecord
scope :preload_approved_by_users, -> { preload(:approved_by_users) }
scope :preload_metrics, -> (relation) { preload(metrics: relation) }
scope :preload_project_and_latest_diff, -> { preload(:source_project, :latest_merge_request_diff) }
- scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: :merge_request_diff_commits) }
+ scope :preload_latest_diff_commit, -> { preload(latest_merge_request_diff: { merge_request_diff_commits: [:commit_author, :committer] }) }
scope :preload_milestoneish_associations, -> { preload_routables.preload(:assignees, :labels) }
scope :with_web_entity_associations, -> { preload(:author, target_project: [:project_feature, group: [:route, :parent], namespace: :route]) }
@@ -1340,7 +1345,7 @@ class MergeRequest < ApplicationRecord
def has_ci?
return false if has_no_commits?
- !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_service)
+ !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration)
end
def branch_missing?
@@ -1551,8 +1556,6 @@ class MergeRequest < ApplicationRecord
end
def has_codequality_mr_diff_report?
- return false unless ::Gitlab::Ci::Features.display_quality_on_mr_diff?(project)
-
actual_head_pipeline&.has_codequality_mr_diff_report?
end
diff --git a/app/models/merge_request/cleanup_schedule.rb b/app/models/merge_request/cleanup_schedule.rb
index 79817269be2..35194b2b318 100644
--- a/app/models/merge_request/cleanup_schedule.rb
+++ b/app/models/merge_request/cleanup_schedule.rb
@@ -1,14 +1,61 @@
# frozen_string_literal: true
class MergeRequest::CleanupSchedule < ApplicationRecord
+ STATUSES = {
+ unstarted: 0,
+ running: 1,
+ completed: 2,
+ failed: 3
+ }.freeze
+
belongs_to :merge_request, inverse_of: :cleanup_schedule
validates :scheduled_at, presence: true
- def self.scheduled_merge_request_ids(limit)
- where('completed_at IS NULL AND scheduled_at <= NOW()')
+ state_machine :status, initial: :unstarted do
+ state :unstarted, value: STATUSES[:unstarted]
+ state :running, value: STATUSES[:running]
+ state :completed, value: STATUSES[:completed]
+ state :failed, value: STATUSES[:failed]
+
+ event :run do
+ transition unstarted: :running
+ end
+
+ event :retry do
+ transition running: :unstarted
+ end
+
+ event :complete do
+ transition running: :completed
+ end
+
+ event :mark_as_failed do
+ transition running: :failed
+ end
+
+ before_transition to: [:completed] do |cleanup_schedule, _transition|
+ cleanup_schedule.completed_at = Time.current
+ end
+
+ before_transition from: :running, to: [:unstarted, :failed] do |cleanup_schedule, _transition|
+ cleanup_schedule.failed_count += 1
+ end
+ end
+
+ scope :scheduled_and_unstarted, -> {
+ where('completed_at IS NULL AND scheduled_at <= NOW() AND status = ?', STATUSES[:unstarted])
.order('scheduled_at DESC')
- .limit(limit)
- .pluck(:merge_request_id)
+ }
+
+ def self.start_next
+ MergeRequest::CleanupSchedule.transaction do
+ cleanup_schedule = scheduled_and_unstarted.lock('FOR UPDATE SKIP LOCKED').first
+
+ next if cleanup_schedule.blank?
+
+ cleanup_schedule.run!
+ cleanup_schedule
+ end
end
end
diff --git a/app/models/merge_request/diff_commit_user.rb b/app/models/merge_request/diff_commit_user.rb
new file mode 100644
index 00000000000..3fc5c9318a4
--- /dev/null
+++ b/app/models/merge_request/diff_commit_user.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+class MergeRequest::DiffCommitUser < ApplicationRecord
+ validates :name, length: { maximum: 512 }
+ validates :email, length: { maximum: 512 }
+ validates :name, presence: true, unless: :email
+ validates :email, presence: true, unless: :name
+
+ # Prepares a value to be inserted into a column in the table
+ # `merge_request_diff_commit_users`. Values in this table are limited to
+ # 512 characters.
+ #
+ # We treat empty strings as NULL values, as there's no point in (for
+ # example) storing a row where both the name and Email are an empty
+ # string. In addition, if we treated them differently we could end up with
+ # two rows: one where field X is NULL, and one where field X is an empty
+ # string. This is redundant, so we avoid storing such data.
+ def self.prepare(value)
+ value.present? ? value[0..511] : nil
+ end
+
+ # Creates a new row, or returns an existing one if a row already exists.
+ def self.find_or_create(name, email)
+ find_or_create_by!(name: name, email: email)
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
+
+ # Finds many (name, email) pairs in bulk.
+ def self.bulk_find(pairs)
+ queries = {}
+ rows = []
+
+ pairs.each do |(name, email)|
+ queries[[name, email]] = where(name: name, email: email).to_sql
+ end
+
+ # We may end up having to query many users. To ensure we don't hit any
+ # query size limits, we get a fixed number of users at a time.
+ queries.values.each_slice(1_000).map do |slice|
+ rows.concat(from("(#{slice.join("\nUNION ALL\n")}) #{table_name}").to_a)
+ end
+
+ rows
+ end
+
+ # Finds or creates rows for the given pairs of names and Emails.
+ #
+ # The `names_and_emails` argument must be an Array/Set of tuples like so:
+ #
+ # [
+ # [name, email],
+ # [name, email],
+ # ...
+ # ]
+ #
+ # This method expects that the names and Emails have already been trimmed to
+ # at most 512 characters.
+ #
+ # The return value is a Hash that maps these tuples to instances of this
+ # model.
+ def self.bulk_find_or_create(pairs)
+ mapping = {}
+ create = []
+
+ # Over time, fewer new rows need to be created. We take advantage of that
+ # here by first finding all rows that already exist, using a limited number
+ # of queries (in most cases only one query will be needed).
+ bulk_find(pairs).each do |row|
+ mapping[[row.name, row.email]] = row
+ end
+
+ pairs.each do |(name, email)|
+ create << { name: name, email: email } unless mapping[[name, email]]
+ end
+
+ return mapping if create.empty?
+
+ # Sometimes we may need to insert new users into the table. We do this in
+ # bulk, so we only need one INSERT for all missing users.
+ insert_all(create, returning: %w[id name email]).each do |row|
+ mapping[[row['name'], row['email']]] =
+ new(id: row['id'], name: row['name'], email: row['email'])
+ end
+
+ # It's possible for (name, email) pairs to be inserted concurrently,
+ # resulting in the above insert not returning anything. Here we get any
+ # remaining users that were created concurrently.
+ bulk_find(pairs.reject { |pair| mapping.key?(pair) }).each do |row|
+ mapping[[row.name, row.email]] = row
+ end
+
+ mapping
+ end
+end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index f58d7788432..d2ea663551d 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -701,7 +701,7 @@ class MergeRequestDiff < ApplicationRecord
end
def load_commits(limit: nil)
- commits = merge_request_diff_commits.limit(limit)
+ commits = merge_request_diff_commits.with_users.limit(limit)
.map { |commit| Commit.from_hash(commit.to_hash, project) }
CommitCollection
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index ed398e0d2e0..466d28301c0 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -9,21 +9,51 @@ class MergeRequestDiffCommit < ApplicationRecord
belongs_to :merge_request_diff
+ # This relation is called `commit_author` and not `author`, as the project
+ # import/export logic treats relations named `author` as instances of the
+ # `User` class.
+ #
+ # NOTE: these columns are _not_ indexed, nor do they use foreign keys.
+ #
+ # This is deliberate, as creating these indexes on GitLab.com takes a _very_
+ # long time. In addition, there's no real need for them either based on how
+ # this data is used.
+ #
+ # For more information, refer to the following:
+ #
+ # - https://gitlab.com/gitlab-com/gl-infra/production/-/issues/5038#note_614592881
+ # - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63669
+ belongs_to :commit_author, class_name: 'MergeRequest::DiffCommitUser'
+ belongs_to :committer, class_name: 'MergeRequest::DiffCommitUser'
+
sha_attribute :sha
alias_attribute :id, :sha
serialize :trailers, Serializers::Json # rubocop:disable Cop/ActiveRecordSerialize
validates :trailers, json_schema: { filename: 'git_trailers' }
+ scope :with_users, -> { preload(:commit_author, :committer) }
+
+ # A list of keys of which their values need to be trimmed before they can be
+ # inserted into the merge_request_diff_commit_users table.
+ TRIM_USER_KEYS =
+ %i[author_name author_email committer_name committer_email].freeze
+
# Deprecated; use `bulk_insert!` from `BulkInsertSafe` mixin instead.
# cf. https://gitlab.com/gitlab-org/gitlab/issues/207989 for progress
def self.create_bulk(merge_request_diff_id, commits)
- rows = commits.map.with_index do |commit, index|
- # See #parent_ids.
- commit_hash = commit.to_hash.except(:parent_ids)
+ commit_hashes, user_tuples = prepare_commits_for_bulk_insert(commits)
+ users = MergeRequest::DiffCommitUser.bulk_find_or_create(user_tuples)
+
+ rows = commit_hashes.map.with_index do |commit_hash, index|
sha = commit_hash.delete(:id)
+ author = users[[commit_hash[:author_name], commit_hash[:author_email]]]
+ committer =
+ users[[commit_hash[:committer_name], commit_hash[:committer_email]]]
commit_hash.merge(
+ commit_author_id: author&.id,
+ committer_id: committer&.id,
merge_request_diff_id: merge_request_diff_id,
relative_order: index,
sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
@@ -36,6 +66,24 @@ class MergeRequestDiffCommit < ApplicationRecord
Gitlab::Database.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert
end
+ def self.prepare_commits_for_bulk_insert(commits)
+ user_tuples = Set.new
+ hashes = commits.map do |commit|
+ hash = commit.to_hash.except(:parent_ids)
+
+ TRIM_USER_KEYS.each do |key|
+ hash[key] = MergeRequest::DiffCommitUser.prepare(hash[key])
+ end
+
+ user_tuples << [hash[:author_name], hash[:author_email]]
+ user_tuples << [hash[:committer_name], hash[:committer_email]]
+
+ hash
+ end
+
+ [hashes, user_tuples]
+ end
+
def self.oldest_merge_request_id_per_commit(project_id, shas)
# This method is defined here and not on MergeRequest, otherwise the SHA
# values used in the WHERE below won't be encoded correctly.
@@ -54,4 +102,20 @@ class MergeRequestDiffCommit < ApplicationRecord
)
.group(:sha)
end
+
+ def author_name
+ commit_author_id ? commit_author.name : super
+ end
+
+ def author_email
+ commit_author_id ? commit_author.email : super
+ end
+
+ def committer_name
+ committer_id ? committer.name : super
+ end
+
+ def committer_email
+ committer_id ? committer.email : super
+ end
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 9ed6c106e45..2168d57693e 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -120,6 +120,19 @@ class Milestone < ApplicationRecord
sorted.with_order_id_desc
end
+ def self.sort_with_expired_last(method)
+ # NOTE: this is a custom ordering of milestones
+ # to prioritize displaying non-expired milestones and milestones without due dates
+ sorted = reorder(Arel.sql("(CASE WHEN due_date IS NULL THEN 1 WHEN due_date >= CURRENT_DATE THEN 0 ELSE 2 END) ASC"))
+ sorted = if method.to_s == 'expired_last_due_date_desc'
+ sorted.order(due_date: :desc)
+ else
+ sorted.order(due_date: :asc)
+ end
+
+ sorted.with_order_id_desc
+ end
+
def self.states_count(projects, groups = nil)
return STATE_COUNT_HASH unless projects || groups
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5f41441058b..5524fec5324 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -116,6 +116,14 @@ class Namespace < ApplicationRecord
)
end
+ scope :sorted_by_similarity_and_parent_id_desc, -> (search) do
+ order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
+ { column: arel_table["path"], multiplier: 1 },
+ { column: arel_table["name"], multiplier: 0.7 }
+ ])
+ reorder(order_expression.desc, Namespace.arel_table['parent_id'].desc.nulls_last, Namespace.arel_table['id'].desc)
+ end
+
# Make sure that the name is same as strong_memoize name in root_ancestor
# method
attr_writer :root_ancestor, :emails_disabled_memoized
@@ -272,7 +280,7 @@ class Namespace < ApplicationRecord
# that belongs to this namespace
def all_projects
if Feature.enabled?(:recursive_approach_for_all_projects, default_enabled: :yaml)
- namespace = user? ? self : self_and_descendants
+ namespace = user? ? self : self_and_descendant_ids
Project.where(namespace: namespace)
else
Project.inside_path(full_path)
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 600abc33471..fc890bf687c 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -15,7 +15,7 @@ class NamespaceSetting < ApplicationRecord
NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal,
:lock_delayed_project_removal, :resource_access_token_creation_allowed,
- :prevent_sharing_groups_outside_hierarchy].freeze
+ :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap].freeze
self.primary_key = :namespace_id
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index d0281f4d974..3d78f384634 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -64,15 +64,28 @@ module Namespaces
traversal_ids.present?
end
- def root_ancestor
- return super if parent.nil?
- return super unless persisted?
+ def use_traversal_ids_for_ancestors?
+ return false unless use_traversal_ids?
+ return false unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor, default_enabled: :yaml)
+
+ traversal_ids.present?
+ end
+
+ def use_traversal_ids_for_root_ancestor?
+ return false unless Feature.enabled?(:use_traversal_ids_for_root_ancestor, default_enabled: :yaml)
- return super if traversal_ids.blank?
- return super unless Feature.enabled?(:use_traversal_ids_for_root_ancestor, default_enabled: :yaml)
+ traversal_ids.present?
+ end
+
+ def root_ancestor
+ return super unless use_traversal_ids_for_root_ancestor?
strong_memoize(:root_ancestor) do
- Namespace.find_by(id: traversal_ids.first)
+ if parent.nil?
+ self
+ else
+ Namespace.find_by(id: traversal_ids.first)
+ end
end
end
@@ -95,14 +108,33 @@ module Namespaces
end
def ancestors(hierarchy_order: nil)
- return super() unless use_traversal_ids?
- return super() unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor, default_enabled: :yaml)
+ return super unless use_traversal_ids_for_ancestors?
return self.class.none if parent_id.blank?
lineage(bottom: parent, hierarchy_order: hierarchy_order)
end
+ def ancestor_ids(hierarchy_order: nil)
+ return super unless use_traversal_ids_for_ancestors?
+
+ hierarchy_order == :desc ? traversal_ids[0..-2] : traversal_ids[0..-2].reverse
+ end
+
+ def self_and_ancestors(hierarchy_order: nil)
+ return super unless use_traversal_ids_for_ancestors?
+
+ return self.class.where(id: id) if parent_id.blank?
+
+ lineage(bottom: self, hierarchy_order: hierarchy_order)
+ end
+
+ def self_and_ancestor_ids(hierarchy_order: nil)
+ return super unless use_traversal_ids_for_ancestors?
+
+ hierarchy_order == :desc ? traversal_ids : traversal_ids.reverse
+ end
+
private
# Update the traversal_ids for the full hierarchy.
@@ -112,8 +144,7 @@ module Namespaces
# Clear any previously memoized root_ancestor as our ancestors have changed.
clear_memoization(:root_ancestor)
- # We cannot rely on Namespaces::Traversal::Linear#root_ancestor because it might be stale
- Namespace::TraversalHierarchy.for_namespace(recursive_root_ancestor).sync_traversal_ids!
+ Namespace::TraversalHierarchy.for_namespace(self).sync_traversal_ids!
end
# Lock the root of the hierarchy we just left, and lock the root of the hierarchy
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index 5a1a9d24117..d9e8743aa50 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -10,7 +10,7 @@ module Namespaces
if persisted?
strong_memoize(:root_ancestor) do
- self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ recursive_self_and_ancestors.reorder(nil).find_by(parent_id: nil)
end
else
parent.root_ancestor
@@ -26,14 +26,19 @@ module Namespaces
alias_method :recursive_self_and_hierarchy, :self_and_hierarchy
# Returns all the ancestors of the current namespaces.
- def ancestors
+ def ancestors(hierarchy_order: nil)
return self.class.none unless parent_id
object_hierarchy(self.class.where(id: parent_id))
- .base_and_ancestors
+ .base_and_ancestors(hierarchy_order: hierarchy_order)
end
alias_method :recursive_ancestors, :ancestors
+ def ancestor_ids(hierarchy_order: nil)
+ recursive_ancestors(hierarchy_order: hierarchy_order).pluck(:id)
+ end
+ alias_method :recursive_ancestor_ids, :ancestor_ids
+
# returns all ancestors upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil, hierarchy_order: nil)
@@ -49,6 +54,11 @@ module Namespaces
end
alias_method :recursive_self_and_ancestors, :self_and_ancestors
+ def self_and_ancestor_ids(hierarchy_order: nil)
+ recursive_self_and_ancestors(hierarchy_order: hierarchy_order).pluck(:id)
+ end
+ alias_method :recursive_self_and_ancestor_ids, :self_and_ancestor_ids
+
# Returns all the descendants of the current namespace.
def descendants
object_hierarchy(self.class.where(parent_id: id))
@@ -63,12 +73,12 @@ module Namespaces
alias_method :recursive_self_and_descendants, :self_and_descendants
def self_and_descendant_ids
- self_and_descendants.select(:id)
+ recursive_self_and_descendants.select(:id)
end
alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids
def object_hierarchy(ancestors_base)
- Gitlab::ObjectHierarchy.new(ancestors_base, options: { use_distinct: Feature.enabled?(:use_distinct_in_object_hierarchy, self) })
+ Gitlab::ObjectHierarchy.new(ancestors_base)
end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index d1a59394ba1..ed341e58436 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -500,6 +500,13 @@ class Note < ApplicationRecord
refs
end
+ def bump_updated_at
+ # Instead of calling touch which is throttled via ThrottledTouch concern,
+ # we bump the updated_at column directly. This also prevents executing
+ # after_commit callbacks that we don't need.
+ update_column(:updated_at, Time.current)
+ end
+
def expire_etag_cache
noteable&.expire_note_etag_cache
end
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 8b052f80395..450a5970ad8 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -80,7 +80,7 @@ module Operations
end
def link_reference_pattern
- @link_reference_pattern ||= super("feature_flags", /(?<feature_flag>\d+)\/edit/)
+ @link_reference_pattern ||= super("feature_flags", %r{(?<feature_flag>\d+)/edit})
end
def reference_postfix
diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb
index f7f7f9f95e9..e20f1b8244a 100644
--- a/app/models/packages/debian.rb
+++ b/app/models/packages/debian.rb
@@ -2,6 +2,10 @@
module Packages
module Debian
+ DISTRIBUTION_REGEX = %r{[a-z0-9][a-z0-9.-]*}i.freeze
+ COMPONENT_REGEX = DISTRIBUTION_REGEX.freeze
+ ARCHITECTURE_REGEX = %r{[a-z0-9][-a-z0-9]*}.freeze
+
def self.table_name_prefix
'packages_debian_'
end
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
index 98c9d5246db..a1eb7120117 100644
--- a/app/models/packages/event.rb
+++ b/app/models/packages/event.rb
@@ -3,7 +3,7 @@
class Packages::Event < ApplicationRecord
belongs_to :package, optional: true
- UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package].freeze
+ UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze
EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001).freeze
EVENT_PREFIX = "i_package"
@@ -21,7 +21,9 @@ class Packages::Event < ApplicationRecord
delete_tag: 7,
delete_tag_bulk: 8,
list_tags: 9,
- cli_metadata: 10
+ cli_metadata: 10,
+ pull_symbol_package: 11,
+ push_symbol_package: 12
}
enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb
index 00d51c21881..a029437c82d 100644
--- a/app/models/packages/go/module.rb
+++ b/app/models/packages/go/module.rb
@@ -33,7 +33,7 @@ module Packages
end
def path_valid?(major)
- m = /\/v(\d+)$/i.match(@name)
+ m = %r{/v(\d+)$}i.match(@name)
case major
when 0, 1
diff --git a/app/models/packages/helm.rb b/app/models/packages/helm.rb
index e021b997bf5..b34ccf907dd 100644
--- a/app/models/packages/helm.rb
+++ b/app/models/packages/helm.rb
@@ -2,6 +2,8 @@
module Packages
module Helm
+ TEMPORARY_PACKAGE_NAME = 'Helm.Temporary.Package'
+
def self.table_name_prefix
'packages_helm_'
end
diff --git a/app/models/packages/nuget.rb b/app/models/packages/nuget.rb
index f152eedb8fc..6bedd488c8a 100644
--- a/app/models/packages/nuget.rb
+++ b/app/models/packages/nuget.rb
@@ -2,6 +2,7 @@
module Packages
module Nuget
TEMPORARY_PACKAGE_NAME = 'NuGet.Temporary.Package'
+ TEMPORARY_SYMBOL_PACKAGE_NAME = 'NuGet.Temporary.SymbolPackage'
def self.table_name_prefix
'packages_nuget_'
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index b040c98ef09..d2e4f46898c 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -158,8 +158,6 @@ class Packages::Package < ApplicationRecord
joins(:project).reorder(keyset_order)
end
- after_commit :update_composer_cache, on: :destroy, if: -> { composer? && Feature.disabled?(:disable_composer_callback) }
-
def self.only_maven_packages_with_path(path, use_cte: false)
if use_cte
# This is an optimization fence which assumes that looking up the Metadatum record by path (globally)
@@ -295,12 +293,6 @@ class Packages::Package < ApplicationRecord
private
- def update_composer_cache
- return unless composer?
-
- ::Packages::Composer::CacheUpdateWorker.perform_async(project_id, name, composer_metadatum.version_cache_sha) # rubocop:disable CodeReuse/Worker
- end
-
def composer_tag_version?
composer? && !Gitlab::Regex.composer_dev_version_regex.match(version.to_s)
end
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 3ef30c035e8..799242a639a 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -27,10 +27,12 @@ class Packages::PackageFile < ApplicationRecord
validates :file_name, uniqueness: { scope: :package }, if: -> { package&.pypi? }
scope :recent, -> { order(id: :desc) }
+ scope :limit_recent, ->(limit) { recent.limit(limit) }
scope :for_package_ids, ->(ids) { where(package_id: ids) }
scope :with_file_name, ->(file_name) { where(file_name: file_name) }
scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) }
scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) }
+ scope :with_format, ->(format) { where(::Packages::PackageFile.arel_table[:file_name].matches("%.#{format}")) }
scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) }
scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) }
scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) }
diff --git a/app/models/plan.rb b/app/models/plan.rb
index f3ef04315f8..e16ecb4c629 100644
--- a/app/models/plan.rb
+++ b/app/models/plan.rb
@@ -14,7 +14,7 @@ class Plan < ApplicationRecord
Gitlab::SafeRequestStore.fetch(:plan_default) do
# find_by allows us to find object (cheaply) against replica DB
# safe_find_or_create_by does stick to primary DB
- find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT)
+ find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT) { |plan| plan.title = DEFAULT.titleize }
end
end
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
index f17078c0cab..bf08da6a1e1 100644
--- a/app/models/plan_limits.rb
+++ b/app/models/plan_limits.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class PlanLimits < ApplicationRecord
+ include IgnorableColumns
+
+ ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22'
+
LimitUndefinedError = Class.new(StandardError)
belongs_to :plan
diff --git a/app/models/project.rb b/app/models/project.rb
index 95ba0973321..9e6e29aadda 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -147,11 +147,7 @@ class Project < ApplicationRecord
has_many :boards
def self.integration_association_name(name)
- if ::Integration.renamed?(name)
- "#{name}_integration"
- else
- "#{name}_service"
- end
+ "#{name}_integration"
end
# Project integrations
@@ -172,25 +168,25 @@ class Project < ApplicationRecord
has_one :flowdock_integration, class_name: 'Integrations::Flowdock'
has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
has_one :irker_integration, class_name: 'Integrations::Irker'
- has_one :jenkins_service, class_name: 'Integrations::Jenkins'
- has_one :jira_service, class_name: 'Integrations::Jira'
- has_one :mattermost_service, class_name: 'Integrations::Mattermost'
- has_one :mattermost_slash_commands_service, class_name: 'Integrations::MattermostSlashCommands'
- has_one :microsoft_teams_service, class_name: 'Integrations::MicrosoftTeams'
- has_one :mock_ci_service, class_name: 'Integrations::MockCi'
- has_one :packagist_service, class_name: 'Integrations::Packagist'
- has_one :pipelines_email_service, class_name: 'Integrations::PipelinesEmail'
- has_one :pivotaltracker_service, class_name: 'Integrations::Pivotaltracker'
- has_one :pushover_service, class_name: 'Integrations::Pushover'
- has_one :redmine_service, class_name: 'Integrations::Redmine'
- has_one :slack_service, class_name: 'Integrations::Slack'
- has_one :slack_slash_commands_service, class_name: 'Integrations::SlackSlashCommands'
- has_one :teamcity_service, class_name: 'Integrations::Teamcity'
- has_one :unify_circuit_service, class_name: 'Integrations::UnifyCircuit'
- has_one :webex_teams_service, class_name: 'Integrations::WebexTeams'
- has_one :youtrack_service, class_name: 'Integrations::Youtrack'
- has_one :prometheus_service, inverse_of: :project
- has_one :mock_monitoring_service
+ has_one :jenkins_integration, class_name: 'Integrations::Jenkins'
+ has_one :jira_integration, class_name: 'Integrations::Jira'
+ has_one :mattermost_integration, class_name: 'Integrations::Mattermost'
+ has_one :mattermost_slash_commands_integration, class_name: 'Integrations::MattermostSlashCommands'
+ has_one :microsoft_teams_integration, class_name: 'Integrations::MicrosoftTeams'
+ has_one :mock_ci_integration, class_name: 'Integrations::MockCi'
+ has_one :mock_monitoring_integration, class_name: 'Integrations::MockMonitoring'
+ has_one :packagist_integration, class_name: 'Integrations::Packagist'
+ has_one :pipelines_email_integration, class_name: 'Integrations::PipelinesEmail'
+ has_one :pivotaltracker_integration, class_name: 'Integrations::Pivotaltracker'
+ has_one :prometheus_integration, class_name: 'Integrations::Prometheus', inverse_of: :project
+ has_one :pushover_integration, class_name: 'Integrations::Pushover'
+ has_one :redmine_integration, class_name: 'Integrations::Redmine'
+ has_one :slack_integration, class_name: 'Integrations::Slack'
+ has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands'
+ has_one :teamcity_integration, class_name: 'Integrations::Teamcity'
+ has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit'
+ has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams'
+ has_one :youtrack_integration, class_name: 'Integrations::Youtrack'
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
@@ -381,6 +377,8 @@ class Project < ApplicationRecord
has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient'
has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList'
+ has_many :error_tracking_errors, inverse_of: :project, class_name: 'ErrorTracking::Error'
+
has_many :timelogs
accepts_nested_attributes_for :variables, allow_destroy: true
@@ -400,7 +398,7 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
- accepts_nested_attributes_for :prometheus_service, update_only: true
+ accepts_nested_attributes_for :prometheus_integration, update_only: true
accepts_nested_attributes_for :alerting_setting, update_only: true
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
@@ -410,36 +408,37 @@ class Project < ApplicationRecord
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level,
:operations_enabled?, :operations_access_level, :security_and_compliance_access_level,
- :container_registry_access_level,
+ :container_registry_access_level, :container_registry_enabled?,
to: :project_feature, allow_nil: true
+ alias_method :container_registry_enabled, :container_registry_enabled?
delegate :show_default_award_emojis, :show_default_award_emojis=,
:show_default_award_emojis?,
to: :project_setting, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
- delegate :squash_option, to: :project_setting
+ delegate :squash_option, :squash_option=, to: :project_setting
+ delegate :previous_default_branch, :previous_default_branch=, to: :project_setting
delegate :no_import?, to: :import_state, allow_nil: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
- delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
+ delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true
delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :dashboard_timezone, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
- delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci, allow_nil: true
- delegate :job_token_scope_enabled, :job_token_scope_enabled=, :job_token_scope_enabled?, to: :ci_cd_settings, prefix: :ci
- delegate :keep_latest_artifact, :keep_latest_artifact=, :keep_latest_artifact?, :keep_latest_artifacts_available?, to: :ci_cd_settings, allow_nil: true
- delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, :restrict_user_defined_variables?,
- to: :ci_cd_settings, allow_nil: true
+ delegate :forward_deployment_enabled, :forward_deployment_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
+ delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
+ delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true
+ delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
:allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?,
to: :project_setting
- delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true
+ delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true
delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
@@ -542,7 +541,7 @@ class Project < ApplicationRecord
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
scope :with_push, -> { joins(:events).merge(Event.pushed_action) }
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
- scope :with_active_jira_services, -> { joins(:integrations).merge(::Integrations::Jira.active) }
+ scope :with_active_jira_integrations, -> { joins(:integrations).merge(::Integrations::Jira.active) }
scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) }
scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) }
scope :inc_routes, -> { includes(:route, namespace: :route) }
@@ -550,9 +549,8 @@ class Project < ApplicationRecord
scope :with_namespace, -> { includes(:namespace) }
scope :with_import_state, -> { includes(:import_state) }
scope :include_project_feature, -> { includes(:project_feature) }
- scope :with_service, ->(service) { joins(service).eager_load(service) }
+ scope :with_integration, ->(integration) { joins(integration).eager_load(integration) }
scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
- scope :with_container_registry, -> { where(container_registry_enabled: true) }
scope :inside_path, ->(path) do
# We need routes alias rs for JOIN so it does not conflict with
# includes(:route) which we use in ProjectsFinder.
@@ -1398,24 +1396,22 @@ class Project < ApplicationRecord
@external_wiki ||= integrations.external_wikis.first
end
- def find_or_initialize_services
- available_services_names = Integration.available_services_names - disabled_services
-
- available_services_names.map do |service_name|
- find_or_initialize_service(service_name)
- end.sort_by(&:title)
+ def find_or_initialize_integrations
+ Integration
+ .available_integration_names
+ .difference(disabled_integrations)
+ .map { find_or_initialize_integration(_1) }
+ .sort_by(&:title)
end
- def disabled_services
- return %w[datadog] unless Feature.enabled?(:datadog_ci_integration, self)
-
+ def disabled_integrations
[]
end
- def find_or_initialize_service(name)
- return if disabled_services.include?(name)
+ def find_or_initialize_integration(name)
+ return if disabled_integrations.include?(name)
- find_service(integrations, name) || build_from_instance_or_template(name) || build_service(name)
+ find_integration(integrations, name) || build_from_instance_or_template(name) || build_integration(name)
end
# rubocop: disable CodeReuse/ServiceClass
@@ -1428,20 +1424,12 @@ class Project < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
- def ci_services
+ def ci_integrations
integrations.where(category: :ci)
end
- def ci_service
- @ci_service ||= ci_services.reorder(nil).find_by(active: true)
- end
-
- def monitoring_services
- integrations.where(category: :monitoring)
- end
-
- def monitoring_service
- @monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true)
+ def ci_integration
+ @ci_integration ||= ci_integrations.reorder(nil).find_by(active: true)
end
def avatar_in_git
@@ -1512,7 +1500,7 @@ class Project < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
- def execute_services(data, hooks_scope = :push_hooks)
+ def execute_integrations(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend
@@ -1525,7 +1513,7 @@ class Project < ApplicationRecord
hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any?
end
- def has_active_services?(hooks_scope = :push_hooks)
+ def has_active_integrations?(hooks_scope = :push_hooks)
integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend
end
@@ -2629,14 +2617,41 @@ class Project < ApplicationRecord
!!read_attribute(:merge_requests_author_approval)
end
- def container_registry_enabled
- if Feature.enabled?(:read_container_registry_access_level, self.namespace, default_enabled: :yaml)
- project_feature.container_registry_enabled?
- else
- read_attribute(:container_registry_enabled)
- end
+ def ci_forward_deployment_enabled?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.forward_deployment_enabled?
+ end
+
+ def ci_job_token_scope_enabled?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.job_token_scope_enabled?
+ end
+
+ def restrict_user_defined_variables?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.restrict_user_defined_variables?
+ end
+
+ def keep_latest_artifacts_available?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.keep_latest_artifacts_available?
+ end
+
+ def keep_latest_artifact?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.keep_latest_artifact?
+ end
+
+ def group_runners_enabled?
+ return false unless ci_cd_settings
+
+ ci_cd_settings.group_runners_enabled?
end
- alias_method :container_registry_enabled?, :container_registry_enabled
private
@@ -2654,28 +2669,28 @@ class Project < ApplicationRecord
project_feature.update!(container_registry_access_level: access_level)
end
- def find_service(services, name)
- services.find { |service| service.to_param == name }
+ def find_integration(integrations, name)
+ integrations.find { _1.to_param == name }
end
def build_from_instance_or_template(name)
- instance = find_service(services_instances, name)
+ instance = find_integration(integration_instances, name)
return Integration.build_from_integration(instance, project_id: id) if instance
- template = find_service(services_templates, name)
+ template = find_integration(integration_templates, name)
return Integration.build_from_integration(template, project_id: id) if template
end
- def build_service(name)
+ def build_integration(name)
Integration.integration_name_to_model(name).new(project_id: id)
end
- def services_templates
- @services_templates ||= Integration.for_template
+ def integration_templates
+ @integration_templates ||= Integration.for_template
end
- def services_instances
- @services_instances ||= Integration.for_instance
+ def integration_instances
+ @integration_instances ||= Integration.for_instance
end
def closest_namespace_setting(name)
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index b025326c6f8..c0c2ea42d46 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -16,7 +16,6 @@ class ProjectCiCdSetting < ApplicationRecord
allow_nil: true
default_value_for :forward_deployment_enabled, true
- default_value_for :job_token_scope_enabled, true
def forward_deployment_enabled?
super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true)
diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb
deleted file mode 100644
index 25ae0f6b60d..00000000000
--- a/app/models/project_services/mock_monitoring_service.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-class MockMonitoringService < MonitoringService
- def title
- 'Mock monitoring'
- end
-
- def description
- 'Mock monitoring service'
- end
-
- def self.to_param
- 'mock_monitoring'
- end
-
- def metrics(environment)
- Gitlab::Json.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
- end
-
- def can_test?
- false
- end
-end
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
deleted file mode 100644
index ea65a200027..00000000000
--- a/app/models/project_services/monitoring_service.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-# Base class for monitoring services
-#
-# These services integrate with a deployment solution like Prometheus
-# to provide additional features for environments.
-class MonitoringService < Integration
- default_value_for :category, 'monitoring'
-
- def self.supported_events
- %w()
- end
-
- def can_query?
- raise NotImplementedError
- end
-
- def query(_, *_)
- raise NotImplementedError
- end
-end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
deleted file mode 100644
index a289c1c2afb..00000000000
--- a/app/models/project_services/prometheus_service.rb
+++ /dev/null
@@ -1,203 +0,0 @@
-# frozen_string_literal: true
-
-class PrometheusService < MonitoringService
- include PrometheusAdapter
-
- # Access to prometheus is directly through the API
- prop_accessor :api_url
- prop_accessor :google_iap_service_account_json
- prop_accessor :google_iap_audience_client_id
- boolean_accessor :manual_configuration
-
- # We need to allow the self-monitoring project to connect to the internal
- # Prometheus instance.
- # Since the internal Prometheus instance is usually a localhost URL, we need
- # to allow localhost URLs when the following conditions are true:
- # 1. project is the self-monitoring project.
- # 2. api_url is the internal Prometheus URL.
- with_options presence: true do
- validates :api_url, public_url: true, if: ->(object) { object.manual_configuration? && !object.allow_local_api_url? }
- validates :api_url, url: true, if: ->(object) { object.manual_configuration? && object.allow_local_api_url? }
- end
-
- before_save :synchronize_service_state
-
- after_save :clear_reactive_cache!
-
- after_commit :track_events
-
- after_create_commit :create_default_alerts
-
- scope :preload_project, -> { preload(:project) }
- scope :with_clusters_with_cilium, -> { joins(project: [:clusters]).merge(Clusters::Cluster.with_available_cilium) }
-
- def initialize_properties
- if properties.nil?
- self.properties = {}
- end
- end
-
- def show_active_box?
- false
- end
-
- def title
- 'Prometheus'
- end
-
- def description
- s_('PrometheusService|Monitor application health with Prometheus metrics and dashboards')
- end
-
- def self.to_param
- 'prometheus'
- end
-
- def fields
- [
- {
- type: 'checkbox',
- name: 'manual_configuration',
- title: s_('PrometheusService|Active'),
- help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
- required: true
- },
- {
- type: 'text',
- name: 'api_url',
- title: 'API URL',
- placeholder: s_('PrometheusService|https://prometheus.example.com/'),
- help: s_('PrometheusService|The Prometheus API base URL.'),
- required: true
- },
- {
- type: 'text',
- name: 'google_iap_audience_client_id',
- title: 'Google IAP Audience Client ID',
- placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
- help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
- autocomplete: 'off',
- required: false
- },
- {
- type: 'textarea',
- name: 'google_iap_service_account_json',
- title: 'Google IAP Service Account JSON',
- placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
- help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
- required: false
- }
- ]
- end
-
- # Check we can connect to the Prometheus API
- def test(*args)
- prometheus_client.ping
- { success: true, result: 'Checked API endpoint' }
- rescue Gitlab::PrometheusClient::Error => err
- { success: false, result: err }
- end
-
- def prometheus_client
- return unless should_return_client?
-
- options = prometheus_client_default_options.merge(
- allow_local_requests: allow_local_api_url?
- )
-
- if behind_iap?
- # Adds the Authorization header
- options[:headers] = iap_client.apply({})
- end
-
- Gitlab::PrometheusClient.new(api_url, options)
- end
-
- def prometheus_available?
- return false if template?
- return false unless project
-
- project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster|
- cluster.integration_prometheus_available?
- end
- end
-
- def allow_local_api_url?
- allow_local_requests_from_web_hooks_and_services? ||
- (self_monitoring_project? && internal_prometheus_url?)
- end
-
- def configured?
- should_return_client?
- end
-
- private
-
- def self_monitoring_project?
- project && project.id == current_settings.self_monitoring_project_id
- end
-
- def internal_prometheus_url?
- api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri
- end
-
- def allow_local_requests_from_web_hooks_and_services?
- current_settings.allow_local_requests_from_web_hooks_and_services?
- end
-
- def should_return_client?
- api_url.present? && manual_configuration? && active? && valid?
- end
-
- def current_settings
- Gitlab::CurrentSettings.current_application_settings
- end
-
- def synchronize_service_state
- self.active = prometheus_available? || manual_configuration?
-
- true
- end
-
- def track_events
- if enabled_manual_prometheus?
- Gitlab::Tracking.event('cluster:services:prometheus', 'enabled_manual_prometheus')
- elsif disabled_manual_prometheus?
- Gitlab::Tracking.event('cluster:services:prometheus', 'disabled_manual_prometheus')
- end
-
- true
- end
-
- def enabled_manual_prometheus?
- manual_configuration_changed? && manual_configuration?
- end
-
- def disabled_manual_prometheus?
- manual_configuration_changed? && !manual_configuration?
- end
-
- def create_default_alerts
- return unless project_id
-
- Prometheus::CreateDefaultAlertsWorker.perform_async(project_id)
- end
-
- def behind_iap?
- manual_configuration? && google_iap_audience_client_id.present? && google_iap_service_account_json.present?
- end
-
- def clean_google_iap_service_account
- return unless google_iap_service_account_json
-
- google_iap_service_account_json
- .then { |json| Gitlab::Json.parse(json) }
- .except('token_credential_uri')
- end
-
- def iap_client
- @iap_client ||= Google::Auth::Credentials
- .new(clean_google_iap_service_account, target_audience: google_iap_audience_client_id)
- .client
- end
-end
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index a85afada901..4586aa2b4b4 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -42,7 +42,7 @@ class ProjectTeam
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
- ProjectMember.add_users(
+ Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass
project,
users,
access_level,
@@ -52,13 +52,12 @@ class ProjectTeam
end
def add_user(user, access_level, current_user: nil, expires_at: nil)
- ProjectMember.add_user(
- project,
- user,
- access_level,
- current_user: current_user,
- expires_at: expires_at
- )
+ Members::Projects::CreatorService.new(project, # rubocop:todo CodeReuse/ServiceClass
+ user,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at)
+ .execute
end
# Remove all users from project team
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index b7a96211fb1..2816aa4cc5b 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -8,6 +8,10 @@ class RepositoryLanguage < ApplicationRecord
default_scope { includes(:programming_language) } # rubocop:disable Cop/DefaultScope
+ scope :with_programming_language, ->(name) do
+ joins(:programming_language).merge(ProgrammingLanguage.with_name_case_insensitive(name))
+ end
+
validates :project, presence: true
validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" }
validates :programming_language, uniqueness: { scope: :project_id }
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index c5203354b9d..1c854cc9941 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -8,7 +8,10 @@ class ServiceDeskSetting < ApplicationRecord
validate :valid_issue_template
validate :valid_project_key
validates :outgoing_name, length: { maximum: 255 }, allow_blank: true
- validates :project_key, length: { maximum: 255 }, allow_blank: true, format: { with: /\A[a-z0-9_]+\z/ }
+ validates :project_key,
+ length: { maximum: 255 },
+ allow_blank: true,
+ format: { with: /\A[a-z0-9_]+\z/, message: -> (setting, data) { _("can contain only lowercase letters, digits, and '_'.") } }
scope :with_project_key, ->(key) { where(project_key: key) }
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index 96fd485b797..3f0e827cf61 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -6,6 +6,7 @@ class Timelog < ApplicationRecord
before_save :set_project
validates :time_spent, :user, presence: true
+ validates :summary, length: { maximum: 255 }
validate :issuable_id_is_present, unless: :importing?
belongs_to :issue, touch: true
diff --git a/app/models/user.rb b/app/models/user.rb
index ce702131151..80b8c9173d1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -375,6 +375,10 @@ class User < ApplicationRecord
Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user)
Ci::DisableUserPipelineSchedulesService.new.execute(user)
end
+
+ after_transition any => :deactivated do |user|
+ NotificationService.new.user_deactivated(user.name, user.notification_email)
+ end
# rubocop: enable CodeReuse/ServiceClass
end
@@ -430,6 +434,7 @@ class User < ApplicationRecord
scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) }
scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) }
scope :with_no_activity, -> { active.where(last_activity_on: nil) }
+ scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
def preferred_language
read_attribute('preferred_language') ||
@@ -554,10 +559,6 @@ class User < ApplicationRecord
end
end
- def for_github_id(id)
- joins(:identities).merge(Identity.with_extern_uid(:github, id))
- end
-
# Find a User by their primary email or any associated secondary email
def find_by_any_email(email, confirmed: false)
return unless email
@@ -809,6 +810,10 @@ class User < ApplicationRecord
# Instance methods
#
+ def default_dashboard?
+ dashboard == self.class.column_defaults['dashboard']
+ end
+
def full_path
username
end
@@ -1231,7 +1236,7 @@ class User < ApplicationRecord
end
def matches_identity?(provider, extern_uid)
- identities.where(provider: provider, extern_uid: extern_uid).exists?
+ identities.with_extern_uid(provider, extern_uid).exists?
end
def project_deploy_keys
@@ -1300,6 +1305,10 @@ class User < ApplicationRecord
save if notification_email_changed? || public_email_changed? || commit_email_changed?
end
+ def admin_unsubscribe!
+ update_column :admin_email_unsubscribed_at, Time.current
+ end
+
def set_projects_limit
# `User.select(:id)` raises
# `ActiveModel::MissingAttributeError: missing attribute: projects_limit`
@@ -1882,9 +1891,11 @@ class User < ApplicationRecord
end
def password_expired_if_applicable?
+ return false if bot?
+ return false unless password_expired? && password_automatically_set?
return false unless allow_password_authentication?
- password_expired?
+ true
end
def can_be_deactivated?
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 2e8ff1b7b49..854992dcd1e 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -31,7 +31,11 @@ class UserCallout < ApplicationRecord
pipeline_needs_banner: 29,
pipeline_needs_hover_tip: 30,
web_ide_ci_environments_guidance: 31,
- security_configuration_upgrade_banner: 32
+ security_configuration_upgrade_banner: 32,
+ cloud_licensing_subscription_activation_banner: 33, # EE-only
+ trial_status_reminder_d14: 34, # EE-only
+ trial_status_reminder_d3: 35, # EE-only
+ security_configuration_devops_alert: 36 # EE-only
}
validates :user, presence: true
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index 7fc01f373c8..e114e30d589 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -7,6 +7,8 @@ class Wiki
include Gitlab::Utils::StrongMemoize
include GlobalID::Identification
+ extend ActiveModel::Naming
+
MARKUPS = { # rubocop:disable Style/MultilineIfModifier
'Markdown' => :markdown,
'RDoc' => :rdoc,
@@ -86,6 +88,7 @@ class Wiki
def create_wiki_repository
repository.create_if_not_exists
+ change_head_to_default_branch
raise CouldNotCreateWikiError unless repository_exists?
rescue StandardError => err
@@ -172,6 +175,7 @@ class Wiki
commit = commit_details(:created, message, title)
wiki.write_page(title, format.to_sym, content, commit)
+ repository.expire_status_cache if repository.empty?
after_wiki_activity
true
@@ -246,7 +250,7 @@ class Wiki
override :default_branch
def default_branch
- wiki.class.default_ref
+ super || Gitlab::Git::Wiki.default_ref(container)
end
def wiki_base_path
@@ -273,6 +277,19 @@ class Wiki
@repository = nil
end
+ def capture_git_error(action, &block)
+ yield block
+ rescue Gitlab::Git::Index::IndexError,
+ Gitlab::Git::CommitError,
+ Gitlab::Git::PreReceiveError,
+ Gitlab::Git::CommandError,
+ ArgumentError => error
+
+ Gitlab::ErrorTracking.log_exception(error, action: action, wiki_id: id)
+
+ false
+ end
+
private
def multi_commit_options(action, message = nil, title = nil)
@@ -306,17 +323,14 @@ class Wiki
"#{user.username} #{action} page: #{title}"
end
- def capture_git_error(action, &block)
- yield block
- rescue Gitlab::Git::Index::IndexError,
- Gitlab::Git::CommitError,
- Gitlab::Git::PreReceiveError,
- Gitlab::Git::CommandError,
- ArgumentError => error
-
- Gitlab::ErrorTracking.log_exception(error, action: action, wiki_id: id)
+ def change_head_to_default_branch
+ # If the wiki has commits in the 'HEAD' branch means that the current
+ # HEAD is pointing to the right branch. If not, it could mean that either
+ # the repo has just been created or that 'HEAD' is pointing
+ # to the wrong branch and we need to rewrite it
+ return if repository.raw_repository.commit_count('HEAD') != 0
- false
+ repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}")
end
end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 9ae5a870323..25438581f2f 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -40,7 +40,6 @@ class WikiPage
end
validates :title, presence: true
- validates :content, presence: true
validate :validate_path_limits, if: :title_changed?
validate :validate_content_size_limit, if: :content_changed?
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index ba06b98e906..0b0edc7c452 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -156,6 +156,7 @@ class GroupPolicy < BasePolicy
enable :set_note_created_at
enable :set_emails_disabled
enable :change_prevent_sharing_groups_outside_hierarchy
+ enable :change_new_user_signups_cap
enable :update_default_branch_protection
enable :create_deploy_token
enable :destroy_deploy_token
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 3cb4644a60d..85547834a2e 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -51,11 +51,12 @@ class ProjectPolicy < BasePolicy
desc "Container registry is disabled"
condition(:container_registry_disabled, scope: :subject) do
- if ::Feature.enabled?(:read_container_registry_access_level, @subject&.namespace, default_enabled: :yaml)
- !access_allowed_to?(:container_registry)
- else
- !project.container_registry_enabled
- end
+ !access_allowed_to?(:container_registry)
+ end
+
+ desc "Container registry is enabled for everyone with access to the project"
+ condition(:container_registry_enabled_for_everyone_with_access, scope: :subject) do
+ project.container_registry_access_level == ProjectFeature::ENABLED
end
desc "Project has an external wiki"
@@ -158,6 +159,10 @@ class ProjectPolicy < BasePolicy
::Feature.enabled?(:build_service_proxy, @subject)
end
+ condition(:respect_protected_tag_for_release_permissions) do
+ ::Feature.enabled?(:evalute_protected_tag_for_release_permissions, @subject, default_enabled: :yaml)
+ end
+
condition(:user_defined_variables_allowed) do
!@subject.restrict_user_defined_variables?
end
@@ -297,10 +302,13 @@ class ProjectPolicy < BasePolicy
enable :guest_access
enable :build_download_code
- enable :build_read_container_image
enable :request_access
end
+ rule { container_registry_enabled_for_everyone_with_access & can?(:public_user_access) }.policy do
+ enable :build_read_container_image
+ end
+
rule { (can?(:public_user_access) | can?(:reporter_access)) & forking_allowed }.policy do
enable :fork_project
end
@@ -649,6 +657,10 @@ class ProjectPolicy < BasePolicy
rule { build_service_proxy_enabled }.enable :build_service_proxy_enabled
+ rule { respect_protected_tag_for_release_permissions & can?(:developer_access) }.policy do
+ enable :destroy_release
+ end
+
rule { can?(:download_code) }.policy do
enable :read_repository_graphs
end
diff --git a/app/policies/release_policy.rb b/app/policies/release_policy.rb
index d7f9e5d7445..bff80d83bef 100644
--- a/app/policies/release_policy.rb
+++ b/app/policies/release_policy.rb
@@ -2,4 +2,20 @@
class ReleasePolicy < BasePolicy
delegate { @subject.project }
+
+ condition(:protected_tag) do
+ access = ::Gitlab::UserAccess.new(@user, container: @subject.project)
+
+ !access.can_create_tag?(@subject.tag)
+ end
+
+ condition(:respect_protected_tag) do
+ ::Feature.enabled?(:evalute_protected_tag_for_release_permissions, @subject.project, default_enabled: :yaml)
+ end
+
+ rule { respect_protected_tag & protected_tag }.policy do
+ prevent :create_release
+ prevent :update_release
+ prevent :destroy_release
+ end
end
diff --git a/app/policies/releases/link_policy.rb b/app/policies/releases/link_policy.rb
index 4a662fafb2f..67a94733c7d 100644
--- a/app/policies/releases/link_policy.rb
+++ b/app/policies/releases/link_policy.rb
@@ -2,6 +2,6 @@
module Releases
class LinkPolicy < BasePolicy
- delegate { @subject.release.project }
+ delegate { @subject.release }
end
end
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 56dd056b9bc..ecc16e2840c 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -26,6 +26,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
highlight(plain: false)
end
+ def raw_plain_data
+ blob.data unless blob.binary?
+ end
+
def web_url
url_helpers.project_blob_url(project, ref_qualified_path)
end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 5b233ad89ec..52f4a4e71a1 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -82,7 +82,7 @@ module Ci
expire_in: artifacts[:expire_in]
}
- if artifacts.dig(:exclude).present? && ::Gitlab::Ci::Features.artifacts_exclude_enabled?
+ if artifacts.dig(:exclude).present?
archive.merge(exclude: artifacts[:exclude])
else
archive
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index e62e2fac835..5f5bbf13f92 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -26,7 +26,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
project_deleted: 'The job belongs to a deleted project',
user_blocked: 'The user who created this job is blocked',
ci_quota_exceeded: 'No more CI minutes available',
- no_matching_runner: 'No matching runner available'
+ no_matching_runner: 'No matching runner available',
+ trace_size_exceeded: 'The job log size limit was reached'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/dev_ops_report/metric_presenter.rb b/app/presenters/dev_ops_report/metric_presenter.rb
index 46b580d1f2a..4d7ac1cd3ec 100644
--- a/app/presenters/dev_ops_report/metric_presenter.rb
+++ b/app/presenters/dev_ops_report/metric_presenter.rb
@@ -50,7 +50,7 @@ module DevOpsReport
description: 'created per active user',
feature: 'ci_pipelines',
blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html',
- docs: help_page_path('ci/README')
+ docs: help_page_path('ci/index')
),
Card.new(
metric: subject,
diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb
index 3c581d4b115..26c78384144 100644
--- a/app/presenters/gitlab/blame_presenter.rb
+++ b/app/presenters/gitlab/blame_presenter.rb
@@ -50,7 +50,7 @@ module Gitlab
def get_commit_data(commit)
CommitData.new.tap do |data|
- data.author_avatar = author_avatar(commit, size: 36, has_tooltip: false)
+ data.author_avatar = author_avatar(commit, size: 36, has_tooltip: false, lazy: true)
data.age_map_class = age_map_class(commit.committed_date, project_duration)
data.commit_link = link_to commit.title, project_commit_path(project, commit.id), class: "cdark", title: commit.title
data.commit_author_link = commit_author_link(commit, avatar: false)
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 7d0fa9e2f8a..6bf5e0bd1b0 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -19,8 +19,8 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
status || "preparing"
else
- ci_service = source_project.try(:ci_service)
- ci_service&.commit_status(diff_head_sha, source_branch)
+ ci_integration = source_project.try(:ci_integration)
+ ci_integration&.commit_status(diff_head_sha, source_branch)
end
end
diff --git a/app/presenters/packages/helm/index_presenter.rb b/app/presenters/packages/helm/index_presenter.rb
new file mode 100644
index 00000000000..a6cfc61c94d
--- /dev/null
+++ b/app/presenters/packages/helm/index_presenter.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Packages
+ module Helm
+ class IndexPresenter
+ include API::Helpers::RelatedResourcesHelpers
+
+ API_VERSION = 'v1'
+ CHANNEL = 'channel'
+ INDEX_YAML_SUFFIX = "/#{CHANNEL}/index.yaml"
+
+ def initialize(project, project_id_param, package_files)
+ @project = project
+ @project_id_param = project_id_param
+ @package_files = package_files
+ end
+
+ def api_version
+ API_VERSION
+ end
+
+ def entries
+ files = @package_files.preload_helm_file_metadata
+ result = Hash.new { |h, k| h[k] = [] }
+
+ files.find_each do |package_file|
+ name = package_file.helm_metadata['name']
+ result[name] << package_file.helm_metadata.merge({
+ 'created' => package_file.created_at.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ'),
+ 'digest' => package_file.file_sha256,
+ 'urls' => ["charts/#{package_file.file_name}"]
+ })
+ end
+
+ result
+ end
+
+ def generated
+ Time.zone.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ')
+ end
+
+ def server_info
+ path = api_v4_projects_packages_helm_index_yaml_path(
+ id: ERB::Util.url_encode(@project_id_param),
+ channel: CHANNEL
+ )
+ {
+ 'contextPath' => path.delete_suffix(INDEX_YAML_SUFFIX)
+ }
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb
index cc7e8619220..09bf1e009a6 100644
--- a/app/presenters/packages/nuget/presenter_helpers.rb
+++ b/app/presenters/packages/nuget/presenter_helpers.rb
@@ -8,6 +8,7 @@ module Packages
BLANK_STRING = ''
PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup'
PACKAGE_DEPENDENCY = 'PackageDependency'
+ NUGET_PACKAGE_FORMAT = 'nupkg'
private
@@ -31,7 +32,7 @@ module Packages
id: package.project_id,
package_name: package.name,
package_version: package.version,
- package_filename: package.package_files.last&.file_name
+ package_filename: package.package_files.with_format(NUGET_PACKAGE_FORMAT).last&.file_name
},
true
)
diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb
index b3cc912b811..033a1845c1c 100644
--- a/app/presenters/packages/nuget/service_index_presenter.rb
+++ b/app/presenters/packages/nuget/service_index_presenter.rb
@@ -8,6 +8,7 @@ module Packages
SERVICE_VERSIONS = {
download: %w[PackageBaseAddress/3.0.0],
search: %w[SearchQueryService SearchQueryService/3.0.0-beta SearchQueryService/3.0.0-rc],
+ symbol: %w[SymbolPackagePublish/4.9.0],
publish: %w[PackagePublish/2.0.0],
metadata: %w[RegistrationsBaseUrl RegistrationsBaseUrl/3.0.0-beta RegistrationsBaseUrl/3.0.0-rc]
}.freeze
@@ -15,13 +16,14 @@ module Packages
SERVICE_COMMENTS = {
download: 'Get package content (.nupkg).',
search: 'Filter and search for packages by keyword.',
+ symbol: 'Push symbol packages.',
publish: 'Push and delete (or unlist) packages.',
metadata: 'Get package metadata.'
}.freeze
VERSION = '3.0.0'
- PROJECT_LEVEL_SERVICES = %i[download publish].freeze
+ PROJECT_LEVEL_SERVICES = %i[download publish symbol].freeze
GROUP_LEVEL_SERVICES = %i[search metadata].freeze
def initialize(project_or_group)
@@ -63,6 +65,8 @@ module Packages
download_service_url
when :search
search_service_url
+ when :symbol
+ symbol_service_url
when :metadata
metadata_service_url
when :publish
@@ -124,6 +128,10 @@ module Packages
def publish_service_url
api_v4_projects_packages_nuget_path(id: @project_or_group.id)
end
+
+ def symbol_service_url
+ api_v4_projects_packages_nuget_symbolpackage_path(id: @project_or_group.id)
+ end
end
end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index fcd3189296a..0d038d19af3 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -135,10 +135,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
ide_edit_path(project, default_branch_or_main, 'README.md')
end
- def add_ci_yml_path
- add_special_file_path(file_name: ci_config_path_or_default)
- end
-
def add_code_quality_ci_yml_path
add_special_file_path(
file_name: ci_config_path_or_default,
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
index e14446bb2f7..ab43800b9f2 100644
--- a/app/presenters/search_service_presenter.rb
+++ b/app/presenters/search_service_presenter.rb
@@ -16,7 +16,7 @@ class SearchServicePresenter < Gitlab::View::Presenter::Delegated
blobs: :with_web_entity_associations
}.freeze
- SORT_ENABLED_SCOPES = %w(issues merge_requests).freeze
+ SORT_ENABLED_SCOPES = %w(issues merge_requests epics).freeze
def search_objects
@search_objects ||= begin
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index e9c710e4a0f..0003a13a7bc 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -17,6 +17,10 @@ class SnippetBlobPresenter < BlobPresenter
snippet_blob_raw_route
end
+ def raw_plain_data
+ blob.data unless blob.binary?
+ end
+
private
def snippet
diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb
index b24148802d0..c5cc8c89fb7 100644
--- a/app/serializers/analytics/cycle_analytics/stage_entity.rb
+++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb
@@ -9,6 +9,21 @@ module Analytics
expose :description
expose :id
expose :custom
+
+ # new API
+ expose :start_event do
+ expose :start_event_identifier, as: :identifier, if: -> (s) { s.custom? }
+ expose :start_event_label, as: :label, using: LabelEntity, if: -> (s) { s.start_event_label_based? }
+ expose :start_event_html_description, as: :html_description
+ end
+
+ expose :end_event do
+ expose :end_event_identifier, as: :identifier, if: -> (s) { s.custom? }
+ expose :end_event_label, as: :label, using: LabelEntity, if: -> (s) { s.end_event_label_based? }
+ expose :end_event_html_description, as: :html_description
+ end
+
+ # old API
expose :start_event_identifier, if: -> (s) { s.custom? }
expose :end_event_identifier, if: -> (s) { s.custom? }
expose :start_event_label, using: LabelEntity, if: -> (s) { s.start_event_label_based? }
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 0616d94a1ed..c3e8b66fe12 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -56,7 +56,7 @@ class MergeRequestWidgetEntity < Grape::Entity
end
expose :merge_request_pipelines_docs_path do |merge_request|
- help_page_path('ci/merge_request_pipelines/index.md')
+ help_page_path('ci/pipelines/merge_request_pipelines.md')
end
expose :ci_environments_status_path do |merge_request|
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index 1118b1aa4fe..a2a9e7375a0 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -23,36 +23,13 @@ class PaginatedDiffEntity < Grape::Entity
end
expose :pagination do
- expose :current_page
- expose :next_page
- expose :total_pages
- expose :next_page_href do |diffs|
- next unless next_page
-
- project = merge_request.target_project
-
- diffs_batch_namespace_project_json_merge_request_path(
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- id: merge_request.iid,
- page: next_page,
- format: :json
- )
+ expose :total_pages do |diffs, options|
+ options.dig(:pagination_data, :total_pages)
end
end
private
- %i[current_page next_page total_pages].each do |method|
- define_method method do
- pagination_data[method]
- end
- end
-
- def pagination_data
- options.fetch(:pagination_data, {})
- end
-
def merge_request
options[:merge_request]
end
diff --git a/app/serializers/service_event_entity.rb b/app/serializers/service_event_entity.rb
index eb4f9c665f2..a1fbfa1d4c4 100644
--- a/app/serializers/service_event_entity.rb
+++ b/app/serializers/service_event_entity.rb
@@ -10,11 +10,11 @@ class ServiceEventEntity < Grape::Entity
expose :event_field_name, as: :name
expose :value do |event|
- service[event_field_name]
+ integration[event_field_name]
end
expose :description do |event|
- ServicesHelper.service_event_description(event)
+ IntegrationsHelper.integration_event_description(integration, event)
end
expose :field, if: -> (_, _) { event_field } do
@@ -22,7 +22,7 @@ class ServiceEventEntity < Grape::Entity
event_field[:name]
end
expose :value do |event|
- service.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend
+ integration.public_send(event_field[:name]) # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -31,14 +31,14 @@ class ServiceEventEntity < Grape::Entity
alias_method :event, :object
def event_field_name
- ServicesHelper.service_event_field_name(event)
+ IntegrationsHelper.integration_event_field_name(event)
end
def event_field
- @event_field ||= service.event_field(event)
+ @event_field ||= integration.event_field(event)
end
- def service
+ def integration
request.service
end
end
diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb
index 464d5f2ecea..089715a42fb 100644
--- a/app/services/alert_management/alerts/update_service.rb
+++ b/app/services/alert_management/alerts/update_service.rb
@@ -37,7 +37,6 @@ module AlertManagement
private
attr_reader :alert, :current_user, :params, :param_errors, :status
- delegate :resolved?, to: :alert
def allowed?
current_user&.can?(:update_alert_management_alert, alert)
@@ -129,7 +128,7 @@ module AlertManagement
def handle_status_change
add_status_change_system_note
- resolve_todos if resolved?
+ resolve_todos if alert.resolved?
end
def add_status_change_system_note
@@ -177,3 +176,5 @@ module AlertManagement
end
end
end
+
+AlertManagement::Alerts::UpdateService.prepend_mod
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 7728982779e..0f2099793ea 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -67,10 +67,8 @@ module ApplicationSettings
end
def update_terms(terms)
- return unless terms.present?
-
# Avoid creating a new terms record if the text is exactly the same.
- terms = terms.strip
+ terms = terms&.strip
return if terms == @application_setting.terms
ApplicationSetting::Term.create(terms: terms)
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 60421f61007..558798c830d 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -16,7 +16,7 @@ class AuditEventService
@author = build_author(author)
@entity = entity
@details = details
- @ip_address = resolve_ip_address(@details, @author)
+ @ip_address = resolve_ip_address(@author)
end
# Builds the @details attribute for authentication
@@ -64,9 +64,8 @@ class AuditEventService
end
end
- def resolve_ip_address(details, author)
- details[:ip_address].presence ||
- Gitlab::RequestContext.instance.client_ip ||
+ def resolve_ip_address(author)
+ Gitlab::RequestContext.instance.client_ip ||
author.current_sign_in_ip
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 5fde346c4ab..d42dcb2fd00 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -115,7 +115,25 @@ module Auth
#
ensure_container_repository!(path, authorized_actions)
- { type: type, name: path.to_s, actions: authorized_actions }
+ {
+ type: type,
+ name: path.to_s,
+ actions: authorized_actions,
+ migration_eligible: migration_eligible(requested_project, authorized_actions)
+ }.compact
+ end
+
+ def migration_eligible(project, actions)
+ return unless actions.include?('push')
+ return unless Feature.enabled?(:container_registry_migration_phase1)
+
+ # The migration process will start by allowing only specific test and gitlab-org projects using the
+ # `container_registry_migration_phase1_allow` FF. We'll then move on to a percentage rollout using this same FF.
+ # To remove the risk of impacting enterprise customers that rely heavily on the registry during the percentage
+ # rollout, we'll add their top-level group/namespace to the `container_registry_migration_phase1_deny` FF. Later,
+ # we'll remove them manually from this deny list, and their new repositories will become eligible.
+ Feature.disabled?(:container_registry_migration_phase1_deny, project.root_ancestor) &&
+ Feature.enabled?(:container_registry_migration_phase1_allow, project)
end
##
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index 7ab87a1af09..3030287e035 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -14,6 +14,7 @@
# or, create a new base class and update this comment.
class BaseService
include BaseServiceUtility
+ include Gitlab::Experiment::Dsl
attr_accessor :project, :current_user, :params
diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb
index 0639acfb399..e3d4da7fb07 100644
--- a/app/services/boards/issues/create_service.rb
+++ b/app/services/boards/issues/create_service.rb
@@ -30,7 +30,9 @@ module Boards
end
def create_issue(params)
- ::Issues::CreateService.new(project: project, current_user: current_user, params: params).execute
+ # NOTE: We are intentionally not doing a spam/CAPTCHA check for issues created via boards.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/29400#note_598479184 for more context.
+ ::Issues::CreateService.new(project: project, current_user: current_user, params: params, spam_params: nil).execute
end
end
end
diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb
index 848e6aaa65a..b5faf2ec281 100644
--- a/app/services/branches/create_service.rb
+++ b/app/services/branches/create_service.rb
@@ -9,12 +9,16 @@ module Branches
return result if result[:status] == :error
- new_branch = repository.add_branch(current_user, branch_name, ref)
+ begin
+ new_branch = repository.add_branch(current_user, branch_name, ref)
+ rescue Gitlab::Git::CommandError => e
+ return error("Failed to create branch '#{branch_name}': #{e}")
+ end
if new_branch
success(new_branch)
else
- error("Invalid reference name: #{ref}")
+ error("Failed to create branch '#{branch_name}': invalid reference name '#{ref}'")
end
rescue Gitlab::Git::PreReceiveError => e
Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref)
diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb
index adb989be218..a7fe4c776b7 100644
--- a/app/services/bulk_create_integration_service.rb
+++ b/app/services/bulk_create_integration_service.rb
@@ -8,7 +8,7 @@ class BulkCreateIntegrationService
end
def execute
- service_list = ServiceList.new(batch, service_hash, association).to_array
+ service_list = ServiceList.new(batch, integration_hash, association).to_array
Integration.transaction do
results = bulk_insert(*service_list)
@@ -31,11 +31,11 @@ class BulkCreateIntegrationService
klass.insert_all(items_to_insert, returning: [:id])
end
- def service_hash
+ def integration_hash
if integration.template?
- integration.to_service_hash
+ integration.to_integration_hash
else
- integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
+ integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
end
end
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index c5a1241e0a4..9a301c260a9 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -1,18 +1,20 @@
# frozen_string_literal: true
+# Downloads a remote file. If no filename is given, it'll use the remote filename
module BulkImports
class FileDownloadService
- FILE_SIZE_LIMIT = 5.gigabytes
- ALLOWED_CONTENT_TYPES = %w(application/gzip application/octet-stream).freeze
-
ServiceError = Class.new(StandardError)
- def initialize(configuration:, relative_url:, dir:, filename:)
+ REMOTE_FILENAME_PATTERN = %r{filename="(?<filename>[^"]+)"}.freeze
+ FILENAME_SIZE_LIMIT = 255 # chars before the extension
+
+ def initialize(configuration:, relative_url:, dir:, file_size_limit:, allowed_content_types:, filename: nil)
@configuration = configuration
@relative_url = relative_url
@filename = filename
@dir = dir
- @filepath = File.join(@dir, @filename)
+ @file_size_limit = file_size_limit
+ @allowed_content_types = allowed_content_types
end
def execute
@@ -30,7 +32,7 @@ module BulkImports
private
- attr_reader :configuration, :relative_url, :dir, :filename, :filepath
+ attr_reader :configuration, :relative_url, :dir, :file_size_limit, :allowed_content_types
def download_file
File.open(filepath, 'wb') do |file|
@@ -39,7 +41,7 @@ module BulkImports
http_client.stream(relative_url) do |chunk|
bytes_downloaded += chunk.size
- raise(ServiceError, 'Invalid downloaded file') if bytes_downloaded > FILE_SIZE_LIMIT
+ validate_size!(bytes_downloaded)
raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200
file.write(chunk)
@@ -53,7 +55,7 @@ module BulkImports
def http_client
@http_client ||= BulkImports::Clients::HTTP.new(
- uri: configuration.url,
+ url: configuration.url,
token: configuration.access_token
)
end
@@ -88,15 +90,59 @@ module BulkImports
end
def validate_content_length
- content_size = headers['content-length']
+ validate_size!(headers['content-length'])
+ end
- raise(ServiceError, 'Invalid content length') if content_size.blank? || content_size.to_i > FILE_SIZE_LIMIT
+ def validate_size!(size)
+ if size.blank?
+ raise ServiceError, 'Missing content-length header'
+ elsif size.to_i > file_size_limit
+ raise ServiceError, "File size %{size} exceeds limit of %{limit}" % {
+ size: ActiveSupport::NumberHelper.number_to_human_size(size),
+ limit: ActiveSupport::NumberHelper.number_to_human_size(file_size_limit)
+ }
+ end
end
def validate_content_type
content_type = headers['content-type']
- raise(ServiceError, 'Invalid content type') if content_type.blank? || ALLOWED_CONTENT_TYPES.exclude?(content_type)
+ raise(ServiceError, 'Invalid content type') if content_type.blank? || allowed_content_types.exclude?(content_type)
+ end
+
+ def filepath
+ @filepath ||= File.join(@dir, filename)
+ end
+
+ def filename
+ @filename.presence || remote_filename
+ end
+
+ # Fetch the remote filename information from the request content-disposition header
+ # - Raises if the filename does not exist
+ # - If the filename is longer then 255 chars truncate it
+ # to be a total of 255 chars (with the extension)
+ def remote_filename
+ @remote_filename ||=
+ headers['content-disposition'].to_s
+ .match(REMOTE_FILENAME_PATTERN) # matches the filename pattern
+ .then { |match| match&.named_captures || {} } # ensures the match is a hash
+ .fetch('filename') # fetches the 'filename' key or raise KeyError
+ .then(&File.method(:basename)) # Ensures to remove path from the filename (../ for instance)
+ .then(&method(:ensure_filename_size)) # Ensures the filename is within the FILENAME_SIZE_LIMIT
+ rescue KeyError
+ raise ServiceError, 'Remote filename not provided in content-disposition header'
+ end
+
+ def ensure_filename_size(filename)
+ if filename.length <= FILENAME_SIZE_LIMIT
+ filename
+ else
+ extname = File.extname(filename)
+ basename = File.basename(filename, extname)[0, FILENAME_SIZE_LIMIT]
+
+ "#{basename}#{extname}"
+ end
end
end
end
diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb
index 29cfd824c12..fc1580ab880 100644
--- a/app/services/bulk_update_integration_service.rb
+++ b/app/services/bulk_update_integration_service.rb
@@ -9,7 +9,7 @@ class BulkUpdateIntegrationService
# rubocop: disable CodeReuse/ActiveRecord
def execute
Integration.transaction do
- Integration.where(id: batch.select(:id)).update_all(service_hash)
+ Integration.where(id: batch.select(:id)).update_all(integration_hash)
if integration.data_fields_present?
integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash)
@@ -22,8 +22,8 @@ class BulkUpdateIntegrationService
attr_reader :integration, :batch
- def service_hash
- integration.to_service_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
+ def integration_hash
+ integration.to_integration_hash.tap { |json| json['inherit_from_id'] = integration.inherit_from_id || integration.id }
end
def data_fields_hash
diff --git a/app/services/captcha/captcha_verification_service.rb b/app/services/captcha/captcha_verification_service.rb
index 45a5a52367c..3ed8ea12f3a 100644
--- a/app/services/captcha/captcha_verification_service.rb
+++ b/app/services/captcha/captcha_verification_service.rb
@@ -7,20 +7,27 @@ module Captcha
class CaptchaVerificationService
include Recaptcha::Verify
+ # Currently the only value that is used out of the request by the reCAPTCHA library
+ # is 'remote_ip'. Therefore, we just create a struct to avoid passing the full request
+ # object through all the service layer objects, and instead just rely on passing only
+ # the required remote_ip value. This eliminates the need to couple the service layer
+ # to the HTTP request (for the purpose of this service, at least).
+ RequestStruct = Struct.new(:remote_ip)
+
+ def initialize(spam_params:)
+ @spam_params = spam_params
+ end
+
##
# Performs verification of a captcha response.
#
- # 'captcha_response' parameter is the response from the user solving a client-side captcha.
- #
- # 'request' parameter is the request which submitted the captcha.
- #
# NOTE: Currently only supports reCAPTCHA, and is not yet used in all places of the app in which
# captchas are verified, but these can be addressed in future MRs. See:
# https://gitlab.com/gitlab-org/gitlab/-/issues/273480
- def execute(captcha_response: nil, request:)
- return false unless captcha_response
+ def execute
+ return false unless spam_params.captcha_response
- @request = request
+ @request = RequestStruct.new(spam_params.ip_address)
Gitlab::Recaptcha.load_configurations!
@@ -31,11 +38,13 @@ module Captcha
# 2. We want control over the wording and i18n of the message
# 3. We want a consistent interface and behavior when adding support for other captcha
# libraries which may not support automatically adding errors to the model.
- verify_recaptcha(response: captcha_response)
+ verify_recaptcha(response: spam_params.captcha_response)
end
private
+ attr_reader :spam_params
+
# The recaptcha library's Recaptcha::Verify#verify_recaptcha method requires that
# 'request' be a readable attribute - it doesn't support passing it as an options argument.
attr_reader :request
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index 2b611c857c7..b422e57baad 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -10,8 +10,16 @@ module Ci
private
def process_subsequent_jobs(processable)
- processable.pipeline.processables.skipped.after_stage(processable.stage_idx).find_each do |processable|
- process(processable)
+ if Feature.enabled?(:ci_same_stage_job_needs, processable.project, default_enabled: :yaml)
+ (stage_dependent_jobs(processable) | needs_dependent_jobs(processable))
+ .each do |processable|
+ process(processable)
+ end
+ else
+ skipped_jobs(processable).after_stage(processable.stage_idx)
+ .find_each do |job|
+ process(job)
+ end
end
end
@@ -24,5 +32,17 @@ module Ci
processable.process(current_user)
end
end
+
+ def skipped_jobs(processable)
+ processable.pipeline.processables.skipped
+ end
+
+ def stage_dependent_jobs(processable)
+ skipped_jobs(processable).scheduling_type_stage.after_stage(processable.stage_idx)
+ end
+
+ def needs_dependent_jobs(processable)
+ skipped_jobs(processable).scheduling_type_dag.with_needs([processable.name])
+ end
end
end
diff --git a/app/services/ci/append_build_trace_service.rb b/app/services/ci/append_build_trace_service.rb
index 602f8c5030d..8200f9790ee 100644
--- a/app/services/ci/append_build_trace_service.rb
+++ b/app/services/ci/append_build_trace_service.rb
@@ -24,6 +24,12 @@ module Ci
body_start = content_range[0].to_i
body_end = body_start + body_data.bytesize
+ if trace_size_exceeded?(body_end)
+ build.drop(:trace_size_exceeded)
+
+ return Result.new(status: 403)
+ end
+
stream_size = build.trace.append(body_data, body_start)
unless stream_size == body_end
@@ -37,6 +43,8 @@ module Ci
private
+ delegate :project, to: :build
+
def stream_range
params.fetch(:content_range)
end
@@ -61,5 +69,10 @@ module Ci
::Gitlab::ErrorTracking
.log_exception(TraceRangeError.new, extra)
end
+
+ def trace_size_exceeded?(size)
+ Feature.enabled?(:ci_jobs_trace_size_limit, project, default_enabled: :yaml) &&
+ project.actual_limits.exceeded?(:ci_jobs_trace_size_limit, size / 1.megabyte)
+ end
end
end
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 1eff76c2e5d..e9ec2338171 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -120,7 +120,7 @@ module Ci
return false if @bridge.triggers_child_pipeline?
if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml)
- pipeline_checksums = @bridge.pipeline.base_and_ancestors.filter_map do |pipeline|
+ pipeline_checksums = @bridge.pipeline.self_and_upstreams.filter_map do |pipeline|
config_checksum(pipeline) unless pipeline.child?
end
@@ -131,7 +131,7 @@ module Ci
def has_max_descendants_depth?
return false unless @bridge.triggers_child_pipeline?
- ancestors_of_new_child = @bridge.pipeline.base_and_ancestors(same_project: true)
+ ancestors_of_new_child = @bridge.pipeline.self_and_ancestors
ancestors_of_new_child.count > MAX_DESCENDANTS_DEPTH
end
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index 1d9533ed76f..494fcb23a06 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -7,7 +7,9 @@ module Ci
Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
- pipeline.destroy!
+ pipeline.cancel_running if pipeline.cancelable? && ::Feature.enabled?(:cancel_pipelines_prior_to_destroy, default_enabled: :yaml)
+
+ pipeline.reset.destroy!
ServiceResponse.success(message: 'Pipeline not found')
rescue ActiveRecord::RecordNotFound
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
index 80c83818d0b..48a6344f576 100644
--- a/app/services/ci/expire_pipeline_cache_service.rb
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -77,7 +77,7 @@ module Ci
store.touch(path)
end
- pipeline.self_with_ancestors_and_descendants.each do |relative_pipeline|
+ pipeline.self_with_upstreams_and_downstreams.each do |relative_pipeline|
store.touch(project_pipeline_path(relative_pipeline.project, relative_pipeline))
store.touch(graphql_pipeline_path(relative_pipeline))
store.touch(graphql_pipeline_sha_path(relative_pipeline.sha))
diff --git a/app/services/ci/job_token_scope/add_project_service.rb b/app/services/ci/job_token_scope/add_project_service.rb
new file mode 100644
index 00000000000..d03ae434b69
--- /dev/null
+++ b/app/services/ci/job_token_scope/add_project_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobTokenScope
+ class AddProjectService < ::BaseService
+ include EditScopeValidations
+
+ def execute(target_project)
+ validate_edit!(project, target_project, current_user)
+
+ link = add_project!(target_project)
+ ServiceResponse.success(payload: { project_link: link })
+
+ rescue ActiveRecord::RecordNotUnique
+ ServiceResponse.error(message: "Target project is already in the job token scope")
+ rescue ActiveRecord::RecordInvalid => e
+ ServiceResponse.error(message: e.message)
+ rescue EditScopeValidations::ValidationError => e
+ ServiceResponse.error(message: e.message)
+ end
+
+ def add_project!(target_project)
+ ::Ci::JobToken::ProjectScopeLink.create!(
+ source_project: project,
+ target_project: target_project,
+ added_by: current_user
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb
new file mode 100644
index 00000000000..15644e529d9
--- /dev/null
+++ b/app/services/ci/job_token_scope/remove_project_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobTokenScope
+ class RemoveProjectService < ::BaseService
+ include EditScopeValidations
+
+ def execute(target_project)
+ validate_edit!(project, target_project, current_user)
+
+ if project == target_project
+ return ServiceResponse.error(message: "Source project cannot be removed from the job token scope")
+ end
+
+ link = ::Ci::JobToken::ProjectScopeLink.for_source_and_target(project, target_project)
+
+ unless link
+ return ServiceResponse.error(message: "Target project is not in the job token scope")
+ end
+
+ if link.destroy
+ ServiceResponse.success
+ else
+ ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { project_link: link })
+ end
+ rescue EditScopeValidations::ValidationError => e
+ ServiceResponse.error(message: e.message)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
index 9978b2d4775..9c8f6b47288 100644
--- a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
+++ b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
@@ -12,15 +12,16 @@ module Ci
return fallback_method.call unless plan_cron&.cron_valid?
now = Time.zone.now
+ plan_min_run = plan_cron.next_time_from(now)
schedule_next_run = schedule_cron.next_time_from(now)
- return schedule_next_run if worker_cron.match?(schedule_next_run) && plan_cron.match?(schedule_next_run)
+ return schedule_next_run if worker_cron.match?(schedule_next_run) && plan_min_run <= schedule_next_run
- plan_next_run = plan_cron.next_time_from(now)
+ plan_next_run = plan_cron.next_time_from(schedule_next_run)
return plan_next_run if worker_cron.match?(plan_next_run)
- worker_next_run = worker_cron.next_time_from(now)
- return worker_next_run if plan_cron.match?(worker_next_run)
+ worker_next_run = worker_cron.next_time_from(schedule_next_run)
+ return worker_next_run if plan_min_run <= worker_next_run
worker_cron.next_time_from(plan_next_run)
end
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
new file mode 100644
index 00000000000..03bdb491200
--- /dev/null
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Ci
+ module Pipelines
+ class AddJobService
+ attr_reader :pipeline
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+
+ raise ArgumentError, "Pipeline must be persisted for this service to be used" unless @pipeline.persisted?
+ end
+
+ def execute!(job, &block)
+ assign_pipeline_attributes(job)
+
+ Ci::Pipeline.transaction do
+ yield(job)
+
+ job.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, @pipeline.project, default_enabled: :yaml)
+ end
+
+ ServiceResponse.success(payload: { job: job })
+ rescue StandardError => e
+ ServiceResponse.error(message: e.message, payload: { job: job })
+ end
+
+ private
+
+ def assign_pipeline_attributes(job)
+ job.pipeline = @pipeline
+ job.project = @pipeline.project
+ job.ref = @pipeline.ref
+ end
+ end
+ end
+end
diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb
index 2f0bcece9e3..a719467253e 100644
--- a/app/services/ci/play_bridge_service.rb
+++ b/app/services/ci/play_bridge_service.rb
@@ -9,8 +9,6 @@ module Ci
bridge.user = current_user
bridge.enqueue!
- next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml)
-
AfterRequeueJobService.new(project, current_user).execute(bridge)
end
end
diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb
index 073c1a2d0e0..c1cf06a4631 100644
--- a/app/services/ci/play_build_service.rb
+++ b/app/services/ci/play_build_service.rb
@@ -11,8 +11,6 @@ module Ci
build.tap do |build|
build.update(user: current_user, job_variables_attributes: job_variables_attributes || [])
- next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml)
-
AfterRequeueJobService.new(project, current_user).execute(build)
end
else
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
new file mode 100644
index 00000000000..99408d529b2
--- /dev/null
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Ci
+ module Queue
+ class BuildQueueService
+ include ::Gitlab::Utils::StrongMemoize
+
+ attr_reader :runner
+
+ def initialize(runner)
+ @runner = runner
+ end
+
+ def new_builds
+ strategy.new_builds
+ end
+
+ ##
+ # This is overridden in EE
+ #
+ def builds_for_shared_runner
+ strategy.builds_for_shared_runner
+ end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def builds_for_group_runner
+ # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
+ groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
+
+ hierarchy_groups = Gitlab::ObjectHierarchy
+ .new(groups)
+ .base_and_descendants
+
+ projects = Project.where(namespace_id: hierarchy_groups)
+ .with_group_runners_enabled
+ .with_builds_enabled
+ .without_deleted
+
+ relation = new_builds.where(project: projects)
+
+ order(relation)
+ end
+
+ def builds_for_project_runner
+ relation = new_builds
+ .where(project: runner.projects.without_deleted.with_builds_enabled)
+
+ order(relation)
+ end
+
+ def builds_queued_before(relation, time)
+ relation.queued_before(time)
+ end
+
+ def builds_for_protected_runner(relation)
+ relation.ref_protected
+ end
+
+ def builds_matching_tag_ids(relation, ids)
+ strategy.builds_matching_tag_ids(relation, ids)
+ end
+
+ def builds_with_any_tags(relation)
+ strategy.builds_with_any_tags(relation)
+ end
+
+ def order(relation)
+ strategy.order(relation)
+ end
+
+ def execute(relation)
+ strategy.build_ids(relation)
+ end
+
+ private
+
+ def strategy
+ strong_memoize(:strategy) do
+ if ::Feature.enabled?(:ci_pending_builds_queue_source, runner, default_enabled: :yaml)
+ Queue::PendingBuildsStrategy.new(runner)
+ else
+ Queue::BuildsTableStrategy.new(runner)
+ end
+ end
+ end
+ end
+ end
+end
+
+Ci::Queue::BuildQueueService.prepend_mod_with('Ci::Queue::BuildQueueService')
diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb
new file mode 100644
index 00000000000..c941734ac40
--- /dev/null
+++ b/app/services/ci/queue/builds_table_strategy.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Ci
+ module Queue
+ class BuildsTableStrategy
+ attr_reader :runner
+
+ def initialize(runner)
+ @runner = runner
+ end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def builds_for_shared_runner
+ relation = new_builds
+ # don't run projects which have not enabled shared runners and builds
+ .joins('INNER JOIN projects ON ci_builds.project_id = projects.id')
+ .where(projects: { shared_runners_enabled: true, pending_delete: false })
+ .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
+ .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0')
+
+ if Feature.enabled?(:ci_queueing_disaster_recovery_disable_fair_scheduling, runner, type: :ops, default_enabled: :yaml)
+ # if disaster recovery is enabled, we fallback to FIFO scheduling
+ relation.order('ci_builds.id ASC')
+ else
+ # Implement fair scheduling
+ # this returns builds that are ordered by number of running builds
+ # we prefer projects that don't use shared runners at all
+ relation
+ .joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id = project_builds.project_id")
+ .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC')
+ end
+ end
+
+ def builds_matching_tag_ids(relation, ids)
+ # pick builds that does not have other tags than runner's one
+ relation.matches_tag_ids(ids)
+ end
+
+ def builds_with_any_tags(relation)
+ # pick builds that have at least one tag
+ relation.with_any_tags
+ end
+
+ def order(relation)
+ relation.order('id ASC')
+ end
+
+ def new_builds
+ ::Ci::Build.pending.unstarted
+ end
+
+ def build_ids(relation)
+ relation.pluck(:id)
+ end
+
+ private
+
+ def running_builds_for_shared_runners
+ ::Ci::Build.running
+ .where(runner: ::Ci::Runner.instance_type)
+ .group(:project_id)
+ .select(:project_id, 'COUNT(*) AS running_builds')
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
new file mode 100644
index 00000000000..55d5cb96a0a
--- /dev/null
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Ci
+ module Queue
+ class PendingBuildsStrategy
+ attr_reader :runner
+
+ def initialize(runner)
+ @runner = runner
+ end
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ def builds_for_shared_runner
+ relation = new_builds
+ # don't run projects which have not enabled shared runners and builds
+ .joins('INNER JOIN projects ON ci_pending_builds.project_id = projects.id')
+ .where(projects: { shared_runners_enabled: true, pending_delete: false })
+ .joins('LEFT JOIN project_features ON ci_pending_builds.project_id = project_features.project_id')
+ .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0')
+
+ if Feature.enabled?(:ci_queueing_disaster_recovery_disable_fair_scheduling, runner, type: :ops, default_enabled: :yaml)
+ # if disaster recovery is enabled, we fallback to FIFO scheduling
+ relation.order('ci_pending_builds.build_id ASC')
+ else
+ # Implement fair scheduling
+ # this returns builds that are ordered by number of running builds
+ # we prefer projects that don't use shared runners at all
+ relation
+ .with(running_builds_for_shared_runners_cte.to_arel)
+ .joins("LEFT JOIN project_builds ON ci_pending_builds.project_id = project_builds.project_id")
+ .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_pending_builds.build_id ASC')
+ end
+ end
+
+ def builds_matching_tag_ids(relation, ids)
+ relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id'))
+ end
+
+ def builds_with_any_tags(relation)
+ relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id'))
+ end
+
+ def order(relation)
+ relation.order('build_id ASC')
+ end
+
+ def new_builds
+ ::Ci::PendingBuild.all
+ end
+
+ def build_ids(relation)
+ relation.pluck(:build_id)
+ end
+
+ private
+
+ def running_builds_for_shared_runners_cte
+ running_builds = ::Ci::RunningBuild
+ .instance_type
+ .group(:project_id)
+ .select(:project_id, 'COUNT(*) AS running_builds')
+
+ ::Gitlab::SQL::CTE
+ .new(:project_builds, running_builds, materialized: true)
+ end
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 6280bf4c986..dc046e1d164 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -103,35 +103,40 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def each_build(params, &blk)
- builds =
+ queue = ::Ci::Queue::BuildQueueService.new(runner)
+
+ builds = begin
if runner.instance_type?
- builds_for_shared_runner
+ queue.builds_for_shared_runner
elsif runner.group_type?
- builds_for_group_runner
+ queue.builds_for_group_runner
else
- builds_for_project_runner
+ queue.builds_for_project_runner
end
+ end
+
+ if runner.ref_protected?
+ builds = queue.builds_for_protected_runner(builds)
+ end
# pick builds that does not have other tags than runner's one
- builds = builds.matches_tag_ids(runner.tags.ids)
+ builds = queue.builds_matching_tag_ids(builds, runner.tags.ids)
# pick builds that have at least one tag
unless runner.run_untagged?
- builds = builds.with_any_tags
+ builds = queue.builds_with_any_tags(builds)
end
# pick builds that older than specified age
if params.key?(:job_age)
- builds = builds.queued_before(params[:job_age].seconds.ago)
+ builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
end
- build_ids = retrieve_queue(-> { builds.pluck(:id) })
+ build_ids = retrieve_queue(-> { queue.execute(builds) })
@metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
- build_ids.each do |build_id|
- yield Ci::Build.find(build_id)
- end
+ build_ids.each { |build_id| yield Ci::Build.find(build_id) }
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -204,7 +209,7 @@ module Ci
# We need to use the presenter here because Gitaly calls in the presenter
# may fail, and we need to ensure the response has been generated.
presented_build = ::Ci::BuildRunnerPresenter.new(build) # rubocop:disable CodeReuse/Presenter
- build_json = ::API::Entities::JobRequest::Response.new(presented_build).to_json
+ build_json = ::API::Entities::Ci::JobRequest::Response.new(presented_build).to_json
Result.new(build, build_json, true)
end
@@ -259,63 +264,6 @@ module Ci
)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def builds_for_shared_runner
- relation = new_builds.
- # don't run projects which have not enabled shared runners and builds
- joins(:project).where(projects: { shared_runners_enabled: true, pending_delete: false })
- .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id')
- .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0')
-
- if Feature.enabled?(:ci_queueing_disaster_recovery, runner, type: :ops, default_enabled: :yaml)
- # if disaster recovery is enabled, we fallback to FIFO scheduling
- relation.order('ci_builds.id ASC')
- else
- # Implement fair scheduling
- # this returns builds that are ordered by number of running builds
- # we prefer projects that don't use shared runners at all
- relation
- .joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id")
- .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC')
- end
- end
-
- def builds_for_project_runner
- new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('id ASC')
- end
-
- def builds_for_group_runner
- # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
- groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
-
- hierarchy_groups = Gitlab::ObjectHierarchy.new(groups, options: { use_distinct: Feature.enabled?(:use_distinct_in_register_job_object_hierarchy) }).base_and_descendants
- projects = Project.where(namespace_id: hierarchy_groups)
- .with_group_runners_enabled
- .with_builds_enabled
- .without_deleted
- new_builds.where(project: projects).order('id ASC')
- end
-
- def running_builds_for_shared_runners
- Ci::Build.running.where(runner: Ci::Runner.instance_type)
- .group(:project_id).select(:project_id, 'count(*) AS running_builds')
- end
-
- def all_builds
- if Feature.enabled?(:ci_pending_builds_queue_join, runner, default_enabled: :yaml)
- Ci::Build.joins(:queuing_entry)
- else
- Ci::Build.all
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def new_builds
- builds = all_builds.pending.unstarted
- builds = builds.ref_protected if runner.ref_protected?
- builds
- end
-
def pre_assign_runner_checks
{
missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? },
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index ea76771b80a..08520c9514c 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -10,10 +10,17 @@ module Ci
resource_group scheduling_type].freeze
end
+ def self.extra_accessors
+ []
+ end
+
def execute(build)
build.ensure_scheduling_type!
reprocess!(build).tap do |new_build|
+ check_assignable_runners!(new_build)
+ next if new_build.failed?
+
Gitlab::OptimisticLocking.retry_lock(new_build, name: 'retry_build', &:enqueue)
AfterRequeueJobService.new(project, current_user).execute(build)
@@ -27,18 +34,15 @@ module Ci
def reprocess!(build)
check_access!(build)
- attributes = self.class.clone_accessors.to_h do |attribute|
- [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
- end
-
- attributes[:user] = current_user
-
- Ci::Build.transaction do
- create_build!(attributes).tap do |new_build|
- new_build.update_older_statuses_retried!
- build.reset # refresh the data to get new values of `retried` and `processed`.
+ new_build = clone_build(build)
+ ::Ci::Pipelines::AddJobService.new(build.pipeline).execute!(new_build) do |job|
+ BulkInsertableAssociations.with_bulk_insert do
+ job.save!
end
end
+ build.reset # refresh the data to get new values of `retried` and `processed`.
+
+ new_build
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -50,13 +54,21 @@ module Ci
end
end
- def create_build!(attributes)
- build = project.builds.new(attributes)
- build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build))
- BulkInsertableAssociations.with_bulk_insert do
- build.save!
+ def check_assignable_runners!(build); end
+
+ def clone_build(build)
+ project.builds.new(build_attributes(build)).tap do |new_build|
+ new_build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(new_build))
end
- build
+ end
+
+ def build_attributes(build)
+ attributes = self.class.clone_accessors.to_h do |attribute|
+ [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ attributes[:user] = current_user
+ attributes
end
end
end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 5cc6b89bfef..02ee40d2cf6 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -13,8 +13,8 @@ module Ci
pipeline.ensure_scheduling_type!
- pipeline.retryable_builds.preload_needs.find_each do |build|
- next unless can?(current_user, :update_build, build)
+ builds_relation(pipeline).find_each do |build|
+ next unless can_be_retried?(build)
Ci::RetryBuildService.new(project, current_user)
.reprocess!(build)
@@ -36,5 +36,17 @@ module Ci
.new(pipeline)
.execute
end
+
+ private
+
+ def builds_relation(pipeline)
+ pipeline.retryable_builds.preload_needs
+ end
+
+ def can_be_retried?(build)
+ can?(current_user, :update_build, build)
+ end
end
end
+
+Ci::RetryPipelineService.prepend_mod_with('Ci::RetryPipelineService')
diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb
index 98d255dec27..2b556a4339d 100644
--- a/app/services/concerns/alert_management/alert_processing.rb
+++ b/app/services/concerns/alert_management/alert_processing.rb
@@ -64,7 +64,7 @@ module AlertManagement
def process_new_alert
if alert.save
- alert.execute_services
+ alert.execute_integrations
SystemNoteService.create_new_alert(alert, alert_source)
process_resolved_alert if resolving_alert?
diff --git a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
new file mode 100644
index 00000000000..23053975313
--- /dev/null
+++ b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Ci
+ module JobTokenScope
+ module EditScopeValidations
+ ValidationError = Class.new(StandardError)
+
+ TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND = "The target_project that you are attempting to access does " \
+ "not exist or you don't have permission to perform this action"
+
+ def validate_edit!(source_project, target_project, current_user)
+ unless source_project.ci_job_token_scope_enabled?
+ raise ValidationError, "Job token scope is disabled for this project"
+ end
+
+ unless can?(current_user, :admin_project, source_project)
+ raise ValidationError, "Insufficient permissions to modify the job token scope"
+ end
+
+ unless can?(current_user, :read_project, target_project)
+ raise ValidationError, TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb
index 6e4824bd784..cbcd0b7f56b 100644
--- a/app/services/concerns/update_repository_storage_methods.rb
+++ b/app/services/concerns/update_repository_storage_methods.rb
@@ -38,11 +38,7 @@ module UpdateRepositoryStorageMethods
rescue StandardError => e
repository_storage_move.do_fail!
- Gitlab::ErrorTracking.track_exception(e, container_klass: container.class.to_s, container_path: container.full_path)
-
- ServiceResponse.error(
- message: s_("UpdateRepositoryStorage|Error moving repository storage for %{container_full_path} - %{message}") % { container_full_path: container.full_path, message: e.message }
- )
+ Gitlab::ErrorTracking.track_and_raise_exception(e, container_klass: container.class.to_s, container_path: container.full_path)
end
private
diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb
index 44ebd45f76e..a1fce45434b 100644
--- a/app/services/design_management/save_designs_service.rb
+++ b/app/services/design_management/save_designs_service.rb
@@ -119,7 +119,7 @@ module DesignManagement
# Returns the latest blobs for the designs as a Hash of `{ Design => Blob }`
def existing_blobs
@existing_blobs ||= begin
- items = designs.map { |d| ['HEAD', d.full_path] }
+ items = designs.map { |d| [target_branch, d.full_path] }
repository.blobs_at(items).each_with_object({}) do |blob, h|
design = designs.find { |d| d.full_path == blob.path }
diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb
new file mode 100644
index 00000000000..bc1f238d81f
--- /dev/null
+++ b/app/services/error_tracking/collect_error_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class CollectErrorService < ::BaseService
+ def execute
+ # Error is a way to group events based on common data like name or cause
+ # of exception. We need to keep a sane balance here between taking too little
+ # and too much data into group logic.
+ error = project.error_tracking_errors.report_error(
+ name: exception['type'], # Example: ActionView::MissingTemplate
+ description: exception['value'], # Example: Missing template posts/show in...
+ actor: event['transaction'], # Example: PostsController#show
+ platform: event['platform'], # Example: ruby
+ timestamp: event['timestamp']
+ )
+
+ # The payload field contains all the data on error including stacktrace in jsonb.
+ # Together with occured_at these are 2 main attributes that we need to save here.
+ error.events.create!(
+ environment: event['environment'],
+ description: exception['type'],
+ level: event['level'],
+ occurred_at: event['timestamp'],
+ payload: event
+ )
+ end
+
+ private
+
+ def event
+ params[:event]
+ end
+
+ def exception
+ event['exception']['values'].first
+ end
+ end
+end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 1ca1bfa0c05..1eb54e13522 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -69,7 +69,7 @@ module Git
# Creating push_data invokes one CommitDelta RPC per commit. Only
# build this data if we actually need it.
project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name)
- project.execute_services(push_data, hook_name) if project.has_active_services?(hook_name)
+ project.execute_integrations(push_data, hook_name) if project.has_active_integrations?(hook_name)
end
def enqueue_invalidate_cache
diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb
index 9109a7f9d58..8fc821b59fc 100644
--- a/app/services/git/wiki_push_service/change.rb
+++ b/app/services/git/wiki_push_service/change.rb
@@ -66,7 +66,8 @@ module Git
def strip_extension(filename)
return unless filename
- File.basename(filename, File.extname(filename))
+ encoded_filename = Gitlab::EncodingHelper.encode_utf8(filename.dup)
+ File.basename(encoded_filename, File.extname(encoded_filename))
end
end
end
diff --git a/app/services/gpg_keys/create_service.rb b/app/services/gpg_keys/create_service.rb
index e41444b2a82..ab8b12732d7 100644
--- a/app/services/gpg_keys/create_service.rb
+++ b/app/services/gpg_keys/create_service.rb
@@ -3,9 +3,17 @@
module GpgKeys
class CreateService < Keys::BaseService
def execute
- key = user.gpg_keys.create(params)
+ key = create(params)
notification_service.new_gpg_key(key) if key.persisted?
key
end
+
+ private
+
+ def create(params)
+ user.gpg_keys.create(params)
+ end
end
end
+
+GpgKeys::CreateService.prepend_mod
diff --git a/app/services/gpg_keys/destroy_service.rb b/app/services/gpg_keys/destroy_service.rb
index cecbfe26611..2e82509897e 100644
--- a/app/services/gpg_keys/destroy_service.rb
+++ b/app/services/gpg_keys/destroy_service.rb
@@ -7,3 +7,5 @@ module GpgKeys
end
end
end
+
+GpgKeys::DestroyService.prepend_mod
diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb
index 5f81e5972b0..8c3ba0a63f2 100644
--- a/app/services/groups/group_links/create_service.rb
+++ b/app/services/groups/group_links/create_service.rb
@@ -24,7 +24,7 @@ module Groups
)
if link.save
- shared_with_group.refresh_members_authorized_projects(direct_members_only: true)
+ shared_with_group.refresh_members_authorized_projects(blocking: false, direct_members_only: true)
success(link: link)
else
error(link.errors.full_messages.to_sentence, 409)
diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb
index 05504a80f46..0e7fd7e0817 100644
--- a/app/services/groups/group_links/destroy_service.rb
+++ b/app/services/groups/group_links/destroy_service.rb
@@ -16,7 +16,7 @@ module Groups
groups_to_refresh = links.map(&:shared_with_group)
groups_to_refresh.uniq.each do |group|
- group.refresh_members_authorized_projects(direct_members_only: true)
+ group.refresh_members_authorized_projects(blocking: false, direct_members_only: true)
end
else
Gitlab::AppLogger.info(
diff --git a/app/services/groups/group_links/update_service.rb b/app/services/groups/group_links/update_service.rb
index 3703d535482..a1411de36d6 100644
--- a/app/services/groups/group_links/update_service.rb
+++ b/app/services/groups/group_links/update_service.rb
@@ -13,7 +13,7 @@ module Groups
group_link.update!(group_link_params)
if requires_authorization_refresh?(group_link_params)
- group_link.shared_with_group.refresh_members_authorized_projects(direct_members_only: true)
+ group_link.shared_with_group.refresh_members_authorized_projects(blocking: false, direct_members_only: true)
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 518d061c654..966d04ceb70 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -46,6 +46,7 @@ module Groups
def ensure_allowed_transfer
raise_transfer_error(:group_is_already_root) if group_is_already_root?
raise_transfer_error(:same_parent_as_current) if same_parent?
+ raise_transfer_error(:has_subscription) if has_subscription?
raise_transfer_error(:invalid_policies) unless valid_policies?
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images?
@@ -73,6 +74,10 @@ module Groups
@new_parent_group && @new_parent_group.id == @group.parent_id
end
+ def has_subscription?
+ @group.paid?
+ end
+
def transfer_to_subgroup?
@new_parent_group && \
@group.self_and_descendants.pluck_primary_key.include?(@new_parent_group.id)
diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb
index 7497ee00d74..f8437290d9b 100644
--- a/app/services/incident_management/incidents/create_service.rb
+++ b/app/services/incident_management/incidents/create_service.rb
@@ -20,14 +20,14 @@ module IncidentManagement
params: {
title: title,
description: description,
- issue_type: ISSUE_TYPE
- }
+ issue_type: ISSUE_TYPE,
+ severity: severity
+ },
+ spam_params: nil
).execute
return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid?
- update_severity_for(issue)
-
success(issue)
end
@@ -42,10 +42,6 @@ module IncidentManagement
def error(message, issue = nil)
ServiceResponse.error(payload: { issue: issue }, message: message)
end
-
- def update_severity_for(issue)
- ::IncidentManagement::Incidents::UpdateSeverityService.new(issue, current_user, severity).execute
- end
end
end
end
diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb
deleted file mode 100644
index faa9277c469..00000000000
--- a/app/services/incident_management/incidents/update_severity_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module IncidentManagement
- module Incidents
- class UpdateSeverityService < BaseService
- def initialize(issuable, current_user, severity)
- super(issuable.project, current_user)
-
- @issuable = issuable
- @severity = severity.to_s.downcase
- @severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(@severity)
- end
-
- def execute
- return unless issuable.supports_severity?
-
- update_severity!
- add_system_note
- end
-
- private
-
- attr_reader :issuable, :severity
-
- def issuable_severity
- issuable.issuable_severity || issuable.build_issuable_severity(issue_id: issuable.id)
- end
-
- def update_severity!
- issuable_severity.update!(severity: severity)
- end
-
- def add_system_note
- ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issuable.id, current_user.id)
- end
- end
- end
-end
diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb
index f8a9eb3ece5..574fe85b466 100644
--- a/app/services/issuable/clone/base_service.rb
+++ b/app/services/issuable/clone/base_service.rb
@@ -66,7 +66,7 @@ module Issuable
def close_issue
close_service = Issues::CloseService.new(project: old_project, current_user: current_user)
- close_service.execute(original_entity, notifications: false, system_note: false)
+ close_service.execute(original_entity, notifications: false, system_note: true)
end
def new_parent
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index 27dbc8b3cc4..4a6b7540ded 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -68,7 +68,10 @@ module Issuable
end
def create_issuable(attributes)
- create_issuable_class.new(project: @project, current_user: @user, params: attributes).execute
+ # NOTE: CSV imports are performed by workers, so we do not have a request context in order
+ # to create a SpamParams object to pass to the issuable create service.
+ spam_params = nil
+ create_issuable_class.new(project: @project, current_user: @user, params: attributes, spam_params: spam_params).execute
end
def email_results_to_user
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 02c1f078a40..8d65865e7da 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -57,6 +57,7 @@ class IssuableBaseService < ::BaseProjectService
filter_assignees(issuable)
filter_milestone
filter_labels
+ filter_severity(issuable)
end
def filter_assignees(issuable)
@@ -135,6 +136,16 @@ class IssuableBaseService < ::BaseProjectService
@labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
end
+ def filter_severity(issuable)
+ severity = params.delete(:severity)
+ return unless severity && issuable.supports_severity?
+
+ severity = IssuableSeverity::DEFAULT unless IssuableSeverity.severities.key?(severity)
+ return if severity == issuable.severity
+
+ params[:issuable_severity_attributes] = { severity: severity }
+ end
+
def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
@@ -352,7 +363,6 @@ class IssuableBaseService < ::BaseProjectService
def change_additional_attributes(issuable)
change_state(issuable)
- change_severity(issuable)
change_subscription(issuable)
change_todo(issuable)
toggle_award(issuable)
@@ -371,12 +381,6 @@ class IssuableBaseService < ::BaseProjectService
end
end
- def change_severity(issuable)
- if severity = params.delete(:severity)
- ::IncidentManagement::Incidents::UpdateSeverityService.new(issuable, current_user, severity).execute
- end
- end
-
def change_subscription(issuable)
case params.delete(:subscription_event)
when 'subscribe'
@@ -443,6 +447,7 @@ class IssuableBaseService < ::BaseProjectService
associations[:time_change] = issuable.time_change if issuable.respond_to?(:time_change)
associations[:description] = issuable.description
associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
+ associations[:severity] = issuable.severity if issuable.supports_severity?
associations
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 1c50bb74176..bf66a33a7b2 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -60,7 +60,7 @@ module Issues
issue_data = Gitlab::Lazy.new { hook_data(issue, action, old_associations: old_associations) }
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
- issue.project.execute_services(issue_data, hooks_scope)
+ issue.project.execute_integrations(issue_data, hooks_scope)
end
def update_project_counter_caches?(issue)
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index 6df32f1104c..cb42334fe32 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -55,9 +55,13 @@ module Issues
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+ # spam checking is not necessary, as no new content is being created. Passing nil for
+ # spam_params will cause SpamActionService to skip checking and return a success response.
+ spam_params = nil
+
# Skip creation of system notes for existing attributes of the issue. The system notes of the old
# issue are copied over so we don't want to end up with duplicate notes.
- CreateService.new(project: target_project, current_user: current_user, params: new_params).execute(skip_system_notes: true)
+ CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true)
end
def queue_copy_designs
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 53f3dc39ba3..30d081996b1 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -4,10 +4,21 @@ module Issues
class CreateService < Issues::BaseService
include ResolveDiscussions
- def execute(skip_system_notes: false)
- @request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
+ # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because
+ # spam_checking is likely to be necessary. However, if there is not a request available in scope
+ # in the caller (for example, an issue created via email) and the required arguments to the
+ # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil.
+ def initialize(project:, current_user: nil, params: {}, spam_params:)
+ # Temporary check to ensure we are no longer passing request in params now that we have
+ # introduced spam_params. Raise an exception if it is present.
+ # Remove after https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58603 is complete.
+ raise if params[:request]
+
+ super(project: project, current_user: current_user, params: params)
+ @spam_params = spam_params
+ end
+ def execute(skip_system_notes: false)
@issue = BuildService.new(project: project, current_user: current_user, params: params).execute
filter_resolve_discussion_params
@@ -18,10 +29,10 @@ module Issues
def before_create(issue)
Spam::SpamActionService.new(
spammable: issue,
- request: request,
+ spam_params: spam_params,
user: current_user,
action: :create
- ).execute(spam_params: spam_params)
+ ).execute
# current_user (defined in BaseService) is not available within run_after_commit block
user = current_user
@@ -64,10 +75,10 @@ module Issues
private
- attr_reader :request, :spam_params
+ attr_reader :spam_params
def user_agent_detail_service
- UserAgentDetailService.new(@issue, request)
+ UserAgentDetailService.new(spammable: @issue, spam_params: spam_params)
end
end
end
diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb
index d150f0e5917..9547698d916 100644
--- a/app/services/issues/duplicate_service.rb
+++ b/app/services/issues/duplicate_service.rb
@@ -28,6 +28,7 @@ module Issues
def relate_two_issues(duplicate_issue, canonical_issue)
params = { target_issuable: canonical_issue }
+
IssueLinks::CreateService.new(duplicate_issue, current_user, params).execute
end
end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index e49123a2993..ff78221c941 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -58,10 +58,13 @@ module Issues
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+ # spam checking is not necessary, as no new content is being created. Passing nil for
+ # spam_params will cause SpamActionService to skip checking and return a success response.
+ spam_params = nil
# Skip creation of system notes for existing attributes of the issue. The system notes of the old
# issue are copied over so we don't want to end up with duplicate notes.
- CreateService.new(project: @target_project, current_user: @current_user, params: new_params).execute(skip_system_notes: true)
+ CreateService.new(project: @target_project, current_user: @current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true)
end
def queue_copy_designs
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index cf2892bf413..9ede5ef728b 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -4,12 +4,17 @@ module Issues
class UpdateService < Issues::BaseService
extend ::Gitlab::Utils::Override
+ # NOTE: For Issues::UpdateService, we default the spam_params to nil, because spam_checking is not
+ # necessary in many cases, and we don't want to require every caller to explicitly pass it as nil
+ # to disable spam checking.
+ def initialize(project:, current_user: nil, params: {}, spam_params: nil)
+ super(project: project, current_user: current_user, params: params)
+ @spam_params = spam_params
+ end
+
def execute(issue)
handle_move_between_ids(issue)
- @request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
-
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end
@@ -25,10 +30,10 @@ module Issues
Spam::SpamActionService.new(
spammable: issue,
- request: request,
+ spam_params: spam_params,
user: current_user,
action: :update
- ).execute(spam_params: spam_params)
+ ).execute
end
def handle_changes(issue, options)
@@ -37,6 +42,7 @@ module Issues
old_labels = old_associations.fetch(:labels, [])
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_assignees = old_associations.fetch(:assignees, [])
+ old_severity = old_associations[:severity]
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.resolve_todos_for_target(issue, current_user)
@@ -69,6 +75,8 @@ module Issues
if added_mentions.present?
notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
end
+
+ handle_severity_change(issue, old_severity)
end
def handle_assignee_changes(issue, old_assignees)
@@ -127,7 +135,7 @@ module Issues
private
- attr_reader :request, :spam_params
+ attr_reader :spam_params
def clone_issue(issue)
target_project = params.delete(:target_clone_project)
@@ -176,6 +184,12 @@ module Issues
end
end
+ def handle_severity_change(issue, old_severity)
+ return unless old_severity && issue.severity != old_severity
+
+ ::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issue.id, current_user.id)
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def issuable_for_positioning(id, board_group_id = nil)
return unless id
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index bae8298d5c8..e4e2736ca2f 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -7,20 +7,20 @@ module Jira
JIRA_API_VERSION = 2
- def initialize(jira_service, params = {})
- @project = jira_service&.project
- @jira_service = jira_service
+ def initialize(jira_integration, params = {})
+ @project = jira_integration&.project
+ @jira_integration = jira_integration
end
def execute
- return ServiceResponse.error(message: _('Jira service not configured.')) unless jira_service&.active?
+ return ServiceResponse.error(message: _('Jira service not configured.')) unless jira_integration&.active?
request
end
private
- attr_reader :jira_service, :project
+ attr_reader :jira_integration, :project
# We have to add the context_path here because the Jira client is not taking it into account
def base_api_url
@@ -37,7 +37,7 @@ module Jira
end
def client
- @client ||= jira_service.client
+ @client ||= jira_integration.client
end
def request
diff --git a/app/services/jira/requests/projects/list_service.rb b/app/services/jira/requests/projects/list_service.rb
index 373c536974a..ac9e9bf0be9 100644
--- a/app/services/jira/requests/projects/list_service.rb
+++ b/app/services/jira/requests/projects/list_service.rb
@@ -6,8 +6,8 @@ module Jira
class ListService < Base
extend ::Gitlab::Utils::Override
- def initialize(jira_service, params = {})
- super(jira_service, params)
+ def initialize(jira_integration, params = {})
+ super(jira_integration, params)
@query = params[:query]
end
diff --git a/app/services/jira_connect_installations/destroy_service.rb b/app/services/jira_connect_installations/destroy_service.rb
new file mode 100644
index 00000000000..cfe58575dcf
--- /dev/null
+++ b/app/services/jira_connect_installations/destroy_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module JiraConnectInstallations
+ class DestroyService
+ def self.execute(installation, jira_connect_base_path, jira_connect_uninstalled_event_path)
+ new(installation, jira_connect_base_path, jira_connect_uninstalled_event_path).execute
+ end
+
+ def initialize(installation, jira_connect_base_path, jira_connect_uninstalled_event_path)
+ @installation = installation
+ @jira_connect_base_path = jira_connect_base_path
+ @jira_connect_uninstalled_event_path = jira_connect_uninstalled_event_path
+ end
+
+ def execute
+ if @installation.instance_url.present?
+ JiraConnect::ForwardEventWorker.perform_async(@installation.id, @jira_connect_base_path, @jira_connect_uninstalled_event_path)
+ return true
+ end
+
+ @installation.destroy
+ end
+ end
+end
diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb
index 5b2f91efc38..667a2836acc 100644
--- a/app/services/jira_import/users_importer.rb
+++ b/app/services/jira_import/users_importer.rb
@@ -32,9 +32,9 @@ module JiraImport
end
def user_mapper_service_factory
- if project.jira_service.data_fields.deployment_server?
+ if project.jira_integration.data_fields.deployment_server?
ServerUsersMapperService.new(user, project, start_at)
- elsif project.jira_service.data_fields.deployment_cloud?
+ elsif project.jira_integration.data_fields.deployment_cloud?
CloudUsersMapperService.new(user, project, start_at)
else
raise ArgumentError
diff --git a/app/services/jira_import/users_mapper_service.rb b/app/services/jira_import/users_mapper_service.rb
index 6c8610bfbf3..13e0dd5120e 100644
--- a/app/services/jira_import/users_mapper_service.rb
+++ b/app/services/jira_import/users_mapper_service.rb
@@ -13,7 +13,7 @@ module JiraImport
def initialize(current_user, project, start_at)
@current_user = current_user
@project = project
- @jira_service = project.jira_service
+ @jira_integration = project.jira_integration
@start_at = start_at
end
@@ -29,14 +29,14 @@ module JiraImport
private
- attr_reader :current_user, :project, :jira_service, :start_at
+ attr_reader :current_user, :project, :jira_integration, :start_at
def jira_users
@jira_users ||= client.get(url)
end
def client
- @client ||= jira_service.client
+ @client ||= jira_integration.client
end
def url
@@ -77,7 +77,7 @@ module JiraImport
end
def project_member_ids
- @project_member_ids ||= MembersFinder.new(project, current_user).execute.select(:user_id)
+ @project_member_ids ||= MembersFinder.new(project, current_user).execute.reselect(:user_id)
end
end
end
diff --git a/app/services/keys/destroy_service.rb b/app/services/keys/destroy_service.rb
index eaf5eb35f58..c7ebb484e94 100644
--- a/app/services/keys/destroy_service.rb
+++ b/app/services/keys/destroy_service.rb
@@ -3,14 +3,22 @@
module Keys
class DestroyService < ::Keys::BaseService
def execute(key)
- key.destroy if destroy_possible?(key)
+ return unless destroy_possible?(key)
+
+ destroy(key)
end
+ private
+
# overridden in EE::Keys::DestroyService
def destroy_possible?(key)
true
end
+
+ def destroy(key)
+ key.destroy
+ end
end
end
-Keys::DestroyService.prepend_mod_with('Keys::DestroyService')
+Keys::DestroyService.prepend_mod
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
new file mode 100644
index 00000000000..f6972f81162
--- /dev/null
+++ b/app/services/members/creator_service.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+module Members
+ # This class serves as more of an app-wide way we add/create members
+ # All roads to add members should take this path.
+ class CreatorService
+ class << self
+ def parsed_access_level(access_level)
+ access_levels.fetch(access_level) { access_level.to_i }
+ end
+
+ def access_levels
+ raise NotImplementedError
+ end
+
+ def add_users(source, users, access_level, current_user: nil, expires_at: nil)
+ return [] unless users.present?
+
+ emails, users, existing_members = parse_users_list(source, users)
+
+ Member.transaction do
+ (emails + users).map! do |user|
+ new(source,
+ user,
+ access_level,
+ existing_members: existing_members,
+ current_user: current_user,
+ expires_at: expires_at)
+ .execute
+ end
+ end
+ end
+
+ private
+
+ def parse_users_list(source, list)
+ emails = []
+ user_ids = []
+ users = []
+ existing_members = {}
+
+ list.each do |item|
+ case item
+ when User
+ users << item
+ when Integer
+ user_ids << item
+ when /\A\d+\Z/
+ user_ids << item.to_i
+ when Devise.email_regexp
+ emails << item
+ end
+ end
+
+ if user_ids.present?
+ users.concat(User.id_in(user_ids))
+ # the below will automatically discard invalid user_ids
+ existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id) # rubocop:todo CodeReuse/ActiveRecord
+ end
+
+ [emails, users, existing_members]
+ end
+ end
+
+ def initialize(source, user, access_level, **args)
+ @source = source
+ @user = user
+ @access_level = self.class.parsed_access_level(access_level)
+ @args = args
+ end
+
+ def execute
+ find_or_build_member
+ update_member
+
+ member
+ end
+
+ private
+
+ attr_reader :source, :user, :access_level, :member, :args
+
+ def update_member
+ return unless can_update_member?
+
+ member.attributes = member_attributes
+
+ if member.request?
+ approve_request
+ else
+ member.save
+ end
+ end
+
+ def can_update_member?
+ # There is no current user for bulk actions, in which case anything is allowed
+ !current_user # inheriting classes will add more logic
+ end
+
+ # Populates the attributes of a member.
+ #
+ # This logic resides in a separate method so that EE can extend this logic,
+ # without having to patch the `add_user` method directly.
+ def member_attributes
+ {
+ created_by: member.created_by || current_user,
+ access_level: access_level,
+ expires_at: args[:expires_at]
+ }
+ end
+
+ def approve_request
+ ::Members::ApproveAccessRequestService.new(current_user,
+ access_level: access_level)
+ .execute(
+ member,
+ skip_authorization: ldap,
+ skip_log_audit_event: ldap
+ )
+ end
+
+ def current_user
+ args[:current_user]
+ end
+
+ def find_or_build_member
+ @user = parse_user_param
+
+ @member = if user.is_a?(User)
+ find_or_initialize_member_by_user
+ else
+ source.members.build(invite_email: user)
+ end
+ end
+
+ # This method is used to find users that have been entered into the "Add members" field.
+ # These can be the User objects directly, their IDs, their emails, or new emails to be invited.
+ def parse_user_param
+ case user
+ when User
+ user
+ when Integer
+ # might not return anything - this needs enhancement
+ User.find_by(id: user) # rubocop:todo CodeReuse/ActiveRecord
+ else
+ # must be an email or at least we'll consider it one
+ User.find_by_any_email(user) || user
+ end
+ end
+
+ def find_or_initialize_member_by_user
+ if existing_members
+ # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/334062
+ # i'm not so sure this is needed as the parse_users_list looks at members_and_requesters...
+ # so it is like we could just do a find or initialize by here and be fine
+ existing_members[user.id] || source.members.build(user_id: user.id)
+ else
+ source.members_and_requesters.find_or_initialize_by(user_id: user.id) # rubocop:todo CodeReuse/ActiveRecord
+ end
+ end
+
+ def existing_members
+ args[:existing_members]
+ end
+
+ def ldap
+ args[:ldap] || false
+ end
+ end
+end
+
+Members::CreatorService.prepend_mod_with('Members::CreatorService')
diff --git a/app/services/members/groups/creator_service.rb b/app/services/members/groups/creator_service.rb
new file mode 100644
index 00000000000..df4d3f59d3b
--- /dev/null
+++ b/app/services/members/groups/creator_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Members
+ module Groups
+ class CreatorService < Members::CreatorService
+ def self.access_levels
+ Gitlab::Access.sym_options_with_owner
+ end
+
+ private
+
+ def can_update_member?
+ super || current_user.can?(:update_group_member, member)
+ end
+ end
+ end
+end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 48010f9c8e7..6298943977b 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -21,7 +21,7 @@ module Members
def validate_invites!
super
- # we need the below due to add_users hitting Member#parse_users_list and ignoring invalid emails
+ # we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails
# ideally we wouldn't need this, but we can't really change the add_users method
valid, invalid = invites.partition { |email| Member.valid_email?(email) }
@invites = valid
diff --git a/app/services/members/projects/creator_service.rb b/app/services/members/projects/creator_service.rb
new file mode 100644
index 00000000000..2e974177075
--- /dev/null
+++ b/app/services/members/projects/creator_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Members
+ module Projects
+ class CreatorService < Members::CreatorService
+ def self.access_levels
+ Gitlab::Access.sym_options
+ end
+
+ private
+
+ def can_update_member?
+ super || current_user.can?(:update_project_member, member) || adding_a_new_owner?
+ end
+
+ def adding_a_new_owner?
+ # this condition is reached during testing setup a lot due to use of `.add_user`
+ member.owner? && member.new_record?
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 7ebdbf94932..099ab1d26e9 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -22,7 +22,7 @@ module MergeRequests
def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations)
merge_request.project.execute_hooks(merge_data, :merge_request_hooks)
- merge_request.project.execute_services(merge_data, :merge_request_hooks)
+ merge_request.project.execute_integrations(merge_data, :merge_request_hooks)
execute_external_hooks(merge_request, merge_data)
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
index cc1e08e1606..79b7eb8d9d8 100644
--- a/app/services/merge_requests/push_options_handler_service.rb
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -142,6 +142,11 @@ module MergeRequests
params[:add_assignee_ids] = params.delete(:assign).keys if params.has_key?(:assign)
params[:remove_assignee_ids] = params.delete(:unassign).keys if params.has_key?(:unassign)
+ if push_options[:milestone]
+ milestone = Milestone.for_projects_and_groups(@project, @project.ancestors_upto)&.find_by_name(push_options[:milestone])
+ params[:milestone] = milestone if milestone
+ end
+
params
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index ae8398e2335..9423194c01d 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -18,12 +18,6 @@ module MergeRequests
end
def rebase
- # Ensure Gitaly isn't already running a rebase
- if source_project.repository.rebase_in_progress?(merge_request.id)
- log_error(exception: nil, message: 'Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
- return false
- end
-
repository.rebase(current_user, merge_request, skip_ci: @skip_ci)
true
diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb
index 222a5c8c79c..d27328f89cd 100644
--- a/app/services/metrics_service.rb
+++ b/app/services/metrics_service.rb
@@ -4,7 +4,7 @@ require 'prometheus/client/formats/text'
class MetricsService
def prometheus_metrics_text
- Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
+ ::Prometheus::Client::Formats::Text.marshal_multiprocess(multiprocess_metrics_path)
end
def metrics_text
diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb
index 80f15f7cc22..25525265e1c 100644
--- a/app/services/namespace_settings/update_service.rb
+++ b/app/services/namespace_settings/update_service.rb
@@ -14,7 +14,15 @@ module NamespaceSettings
def execute
validate_resource_access_token_creation_allowed_param
- validate_prevent_sharing_groups_outside_hierarchy_param
+
+ validate_settings_param_for_root_group(
+ param_key: :prevent_sharing_groups_outside_hierarchy,
+ user_policy: :change_prevent_sharing_groups_outside_hierarchy
+ )
+ validate_settings_param_for_root_group(
+ param_key: :new_user_signups_cap,
+ user_policy: :change_new_user_signups_cap
+ )
if group.namespace_settings
group.namespace_settings.attributes = settings_params
@@ -34,12 +42,17 @@ module NamespaceSettings
end
end
- def validate_prevent_sharing_groups_outside_hierarchy_param
- return if settings_params[:prevent_sharing_groups_outside_hierarchy].nil?
+ def validate_settings_param_for_root_group(param_key:, user_policy:)
+ return if settings_params[param_key].nil?
+
+ unless can?(current_user, user_policy, group)
+ settings_params.delete(param_key)
+ group.namespace_settings.errors.add(param_key, _('can only be changed by a group admin.'))
+ end
- unless can?(current_user, :change_prevent_sharing_groups_outside_hierarchy, group)
- settings_params.delete(:prevent_sharing_groups_outside_hierarchy)
- group.namespace_settings.errors.add(:prevent_sharing_groups_outside_hierarchy, _('can only be changed by a group admin.'))
+ unless group.root?
+ settings_params.delete(param_key)
+ group.namespace_settings.errors.add(param_key, _('only available on top-level groups.'))
end
end
end
diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb
index 3461362b48c..f7f0cf9abe8 100644
--- a/app/services/namespaces/in_product_marketing_emails_service.rb
+++ b/app/services/namespaces/in_product_marketing_emails_service.rb
@@ -2,8 +2,6 @@
module Namespaces
class InProductMarketingEmailsService
- include Gitlab::Experimentation::GroupTypes
-
TRACKS = {
create: {
interval_days: [1, 5, 10],
@@ -61,12 +59,6 @@ module Namespaces
attr_reader :track, :interval, :in_product_marketing_email_records
def send_email_for_group(group)
- if Gitlab.com?
- experiment_enabled_for_group = experiment_enabled_for_group?(group)
- experiment_add_group(group, experiment_enabled_for_group)
- return unless experiment_enabled_for_group
- end
-
users_for_group(group).each do |user|
if can_perform_action?(user, group)
send_email(user, group)
@@ -77,15 +69,6 @@ module Namespaces
save_tracked_emails!
end
- def experiment_enabled_for_group?(group)
- Gitlab::Experimentation.in_experiment_group?(:in_product_marketing_emails, subject: group)
- end
-
- def experiment_add_group(group, experiment_enabled_for_group)
- variant = experiment_enabled_for_group ? GROUP_EXPERIMENTAL : GROUP_CONTROL
- Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group)
- end
-
def groups_for_track
onboarding_progress_scope = OnboardingProgress
.completed_actions_with_latest_in_range(completed_actions, range)
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index b7ccdbc1cff..c9375fe14a1 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -39,7 +39,7 @@ module Notes
hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
note.project.execute_hooks(note_data, hooks_scope)
- note.project.execute_services(note_data, hooks_scope)
+ note.project.execute_integrations(note_data, hooks_scope)
end
end
end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 9dfcfe748da..afc9015e758 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -396,6 +396,7 @@ class NotificationService
recipients.each do |recipient|
mailer.service_desk_new_note_email(issue.id, note.id, recipient).deliver_later
+ Gitlab::Metrics::BackgroundTransaction.current&.add_event(:service_desk_new_note_email)
end
end
@@ -427,6 +428,10 @@ class NotificationService
mailer.user_admin_rejection_email(name, email).deliver_later
end
+ def user_deactivated(name, email)
+ mailer.user_deactivated_email(name, email).deliver_later
+ end
+
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
diff --git a/app/services/packages/conan/search_service.rb b/app/services/packages/conan/search_service.rb
index 143fd8a627b..31ee9bea084 100644
--- a/app/services/packages/conan/search_service.rb
+++ b/app/services/packages/conan/search_service.rb
@@ -41,7 +41,7 @@ module Packages
end
def search_for_single_package(query)
- name, version, username, _ = query.split(/[@\/]/)
+ name, version, username, _ = query.split(%r{[@/]})
full_path = Packages::Conan::Metadatum.full_path_from(package_username: username)
project = Project.find_by_full_path(full_path)
return unless Ability.allowed?(current_user, :read_package, project)
diff --git a/app/services/packages/destroy_package_service.rb b/app/services/packages/destroy_package_service.rb
new file mode 100644
index 00000000000..697f1fa3ac8
--- /dev/null
+++ b/app/services/packages/destroy_package_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Packages
+ class DestroyPackageService < BaseContainerService
+ alias_method :package, :container
+
+ def execute
+ return service_response_error("You don't have access to this package", 403) unless user_can_delete_package?
+
+ package.destroy!
+
+ package.sync_maven_metadata(current_user)
+
+ service_response_success('Package was successfully deleted')
+ rescue StandardError
+ service_response_error('Failed to remove the package', 400)
+ end
+
+ private
+
+ def service_response_error(message, http_status)
+ ServiceResponse.error(message: message, http_status: http_status)
+ end
+
+ def service_response_success(message)
+ ServiceResponse.success(message: message)
+ end
+
+ def user_can_delete_package?
+ can?(current_user, :destroy_package, package.project)
+ end
+ end
+end
diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb
index 63da98dde43..66abd189153 100644
--- a/app/services/packages/nuget/metadata_extraction_service.rb
+++ b/app/services/packages/nuget/metadata_extraction_service.rb
@@ -18,6 +18,7 @@ module Packages
XPATH_DEPENDENCIES = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:dependency'
XPATH_DEPENDENCY_GROUPS = '//xmlns:package/xmlns:metadata/xmlns:dependencies/xmlns:group'
XPATH_TAGS = '//xmlns:package/xmlns:metadata/xmlns:tags'
+ XPATH_PACKAGE_TYPES = '//xmlns:package/xmlns:metadata/xmlns:packageTypes/xmlns:packageType'
MAX_FILE_SIZE = 4.megabytes.freeze
@@ -57,6 +58,7 @@ module Packages
.tap do |metadata|
metadata[:package_dependencies] = extract_dependencies(doc)
metadata[:package_tags] = extract_tags(doc)
+ metadata[:package_types] = extract_package_types(doc)
end
end
@@ -85,6 +87,10 @@ module Packages
}.compact
end
+ def extract_package_types(doc)
+ doc.xpath(XPATH_PACKAGE_TYPES).map { |node| node.attr('name') }.uniq
+ end
+
def extract_tags(doc)
tags = doc.xpath(XPATH_TAGS).text
diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
index 8210072eab3..2d1733421fd 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -8,6 +8,7 @@ module Packages
# used by ExclusiveLeaseGuard
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+ SYMBOL_PACKAGE_IDENTIFIER = 'SymbolsPackage'
InvalidMetadataError = Class.new(StandardError)
@@ -20,7 +21,13 @@ module Packages
try_obtain_lease do
@package_file.transaction do
- package = existing_package ? link_to_existing_package : update_linked_package
+ if existing_package
+ package = link_to_existing_package
+ elsif symbol_package?
+ raise InvalidMetadataError, 'symbol package is invalid, matching package does not exist'
+ else
+ package = update_linked_package
+ end
update_package(package)
@@ -39,6 +46,8 @@ module Packages
private
def update_package(package)
+ return if symbol_package?
+
::Packages::Nuget::SyncMetadatumService
.new(package, metadata.slice(:project_url, :license_url, :icon_url))
.execute
@@ -103,6 +112,14 @@ module Packages
metadata.fetch(:package_tags, [])
end
+ def package_types
+ metadata.fetch(:package_types, [])
+ end
+
+ def symbol_package?
+ package_types.include?(SYMBOL_PACKAGE_IDENTIFIER)
+ end
+
def metadata
strong_memoize(:metadata) do
::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute
@@ -110,7 +127,7 @@ module Packages
end
def package_filename
- "#{package_name.downcase}.#{package_version.downcase}.nupkg"
+ "#{package_name.downcase}.#{package_version.downcase}.#{symbol_package? ? 'snupkg' : 'nupkg'}"
end
# used by ExclusiveLeaseGuard
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index faacabbb16c..a6d49f03c0b 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -32,11 +32,11 @@ class PostReceiveService
response.add_alert_message(broadcast_message)
response.add_merge_request_urls(merge_request_urls)
- # Neither User nor Project are guaranteed to be returned; an orphaned write deploy
+ # Neither User nor Repository are guaranteed to be returned; an orphaned write deploy
# key could be used
- if user && project
- redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)
- project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id)
+ if user && repository
+ redirect_message = Gitlab::Checks::ContainerMoved.fetch_message(user, repository)
+ project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user, repository)
response.add_basic_message(redirect_message)
response.add_basic_message(project_created_message)
@@ -94,6 +94,8 @@ class PostReceiveService
end
def record_onboarding_progress
+ return unless project
+
OnboardingProgressService.new(project.namespace).execute(action: :git_write)
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 7dd9280e5b1..9a5c260e488 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -108,11 +108,7 @@ module Projects
current_user.invalidate_personal_projects_count
- if Feature.enabled?(:projects_post_creation_worker, current_user, default_enabled: :yaml)
- Projects::PostCreationWorker.perform_async(@project.id)
- else
- create_prometheus_service
- end
+ Projects::PostCreationWorker.perform_async(@project.id)
create_readme if @initialize_with_readme
end
@@ -151,7 +147,7 @@ module Projects
branch_name: @default_branch.presence || @project.default_branch_or_main,
commit_message: 'Initial commit',
file_path: 'README.md',
- file_content: "# #{@project.name}\n\n#{@project.description}"
+ file_content: experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project)
}
Files::CreateService.new(@project, current_user, commit_attrs).execute
@@ -191,25 +187,6 @@ module Projects
@project
end
- # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326665
- def create_prometheus_service
- service = @project.find_or_initialize_service(::PrometheusService.to_param)
-
- # If the service has already been inserted in the database, that
- # means it came from a template, and there's nothing more to do.
- return if service.persisted?
-
- if service.prometheus_available?
- service.save!
- else
- @project.prometheus_service = nil
- end
-
- rescue ActiveRecord::RecordInvalid => e
- Gitlab::ErrorTracking.track_exception(e, extra: { project_id: project.id })
- @project.prometheus_service = nil
- end
-
def set_project_name_from_path
# if both name and path set - everything is ok
return if @project.name.present? && @project.path.present?
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index fc5c936b378..a0232779c97 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -13,7 +13,7 @@ module Projects
)
if link.save
- setup_authorizations(group, link.group_access)
+ setup_authorizations(group)
success(link: link)
else
error(link.errors.full_messages.to_sentence, 409)
@@ -22,9 +22,8 @@ module Projects
private
- def setup_authorizations(group, group_access = nil)
- AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker.perform_async(
- project.id, group.id, group_access)
+ def setup_authorizations(group)
+ AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
# AuthorizedProjectsWorker uses an exclusive lease per user but
# specialized workers might have synchronization issues. Until we
diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb
index 7de4b7a211d..475ab17f1a1 100644
--- a/app/services/projects/group_links/update_service.rb
+++ b/app/services/projects/group_links/update_service.rb
@@ -12,15 +12,29 @@ module Projects
def execute(group_link_params)
group_link.update!(group_link_params)
- if requires_authorization_refresh?(group_link_params)
- group_link.group.refresh_members_authorized_projects
- end
+ refresh_authorizations if requires_authorization_refresh?(group_link_params)
end
private
attr_reader :group_link
+ def refresh_authorizations
+ if Feature.enabled?(:specialized_worker_for_project_share_update_auth_recalculation)
+ AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
+
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ group_link.group.refresh_members_authorized_projects(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
+ else
+ group_link.group.refresh_members_authorized_projects
+ end
+ end
+
def requires_authorization_refresh?(params)
params.include?(:group_access)
end
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index 9e2edf7c4ef..fe9dce26029 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -22,7 +22,7 @@ module Projects
def execute
return unless project&.lfs_enabled? && lfs_download_object
return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid?
- return link_existing_lfs_object! if lfs_size > LARGE_FILE_SIZE && lfs_object
+ return link_existing_lfs_object! if Feature.enabled?(:lfs_link_existing_object, project, default_enabled: :yaml) && lfs_size > LARGE_FILE_SIZE && lfs_object
wrap_download_errors do
download_lfs_file!
@@ -61,8 +61,10 @@ module Projects
def download_and_save_file!(file)
digester = Digest::SHA256.new
fetch_file do |fragment|
- digester << fragment
- file.write(fragment)
+ if digest_fragment?(fragment)
+ digester << fragment
+ file.write(fragment)
+ end
raise_size_error! if file.size > lfs_size
end
@@ -71,6 +73,10 @@ module Projects
raise_oid_error! if digester.hexdigest != lfs_oid
end
+ def digest_fragment?(fragment)
+ fragment.http_response.is_a?(Net::HTTPSuccess)
+ end
+
def download_options
http_options = { headers: lfs_headers, stream_body: true }
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index c0734171ee5..2cc6bcdf57c 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -102,10 +102,10 @@ module Projects
def prometheus_integration_params
return {} unless attrs = params[:prometheus_integration_attributes]
- service = project.find_or_initialize_service(::PrometheusService.to_param)
- service.assign_attributes(attrs)
+ integration = project.find_or_initialize_integration(::Integrations::Prometheus.to_param)
+ integration.assign_attributes(attrs)
- { prometheus_service_attributes: service.attributes.except(*%w(id project_id created_at updated_at)) }
+ { prometheus_integration_attributes: integration.attributes.except(*%w[id project_id created_at updated_at]) }
end
def incident_management_setting_params
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index e681b5643ee..6be3b1b5a6f 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -20,7 +20,7 @@ module Projects
rescue Exception => e # rubocop:disable Lint/RescueException
attempt_restore_repositories(source_project)
- if e.class == Exception
+ if e.instance_of?(Exception)
raise StandardError, e.message
else
raise
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index e1eb1374d14..c1bf2e68436 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -67,7 +67,7 @@ module Projects
end
def valid_for_manual?(token)
- prometheus = project.find_or_initialize_service('prometheus')
+ prometheus = project.find_or_initialize_integration('prometheus')
return false unless prometheus.manual_configuration?
if setting = project.alerting_setting
diff --git a/app/services/projects/protect_default_branch_service.rb b/app/services/projects/protect_default_branch_service.rb
index 1d3fb523448..0111b9e377a 100644
--- a/app/services/projects/protect_default_branch_service.rb
+++ b/app/services/projects/protect_default_branch_service.rb
@@ -22,7 +22,7 @@ module Projects
# Ensure HEAD points to the default branch in case it is not master
project.change_head(default_branch)
- create_protected_branch if protect_branch?
+ create_protected_branch if protect_branch? && !protected_branch_exists?
end
def create_protected_branch
@@ -44,6 +44,10 @@ module Projects
!ProtectedBranch.protected?(project, default_branch)
end
+ def protected_branch_exists?
+ project.protected_branches.find_by_name(default_branch).present?
+ end
+
def default_branch
project.default_branch
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index d9e49dfae61..fb0fea756bc 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -139,7 +139,19 @@ module Projects
user_ids = @old_namespace.user_ids_for_project_authorizations |
@new_namespace.user_ids_for_project_authorizations
- UserProjectAccessChangedService.new(user_ids).execute
+ if Feature.enabled?(:specialized_worker_for_project_transfer_auth_recalculation)
+ AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id)
+
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ UserProjectAccessChangedService.new(user_ids).execute(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
+ else
+ UserProjectAccessChangedService.new(user_ids).execute
+ end
end
def rollback_side_effects
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 8ea35131339..a90c22c7de5 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -31,10 +31,11 @@ module Projects
register_attempt
# Create status notifying the deployment of pages
- @status = create_status
- @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml)
- @status.enqueue!
- @status.run!
+ @status = build_commit_status
+ ::Ci::Pipelines::AddJobService.new(@build.pipeline).execute!(@status) do |job|
+ job.enqueue!
+ job.run!
+ end
raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
raise InvalidStateError, 'build SHA is outdated for this ref' unless latest?
@@ -70,12 +71,9 @@ module Projects
super
end
- def create_status
+ def build_commit_status
GenericCommitStatus.new(
- project: project,
- pipeline: build.pipeline,
user: build.user,
- ref: build.ref,
stage: 'deploy',
name: 'pages:deploy'
)
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
index eac84337967..6c29ba81910 100644
--- a/app/services/projects/update_remote_mirror_service.rb
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -65,7 +65,7 @@ module Projects
# TODO: Support LFS sync over SSH
# https://gitlab.com/gitlab-org/gitlab/-/issues/249587
- return unless remote_mirror.url =~ /\Ahttps?:\/\//i
+ return unless remote_mirror.url =~ %r{\Ahttps?://}i
return unless remote_mirror.password_auth?
Lfs::PushService.new(
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 4351a66351d..d6e7f165d72 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -66,6 +66,8 @@ module Projects
previous_default_branch = project.default_branch
if project.change_head(params[:default_branch])
+ params[:previous_default_branch] = previous_default_branch
+
after_default_branch_change(previous_default_branch)
else
raise ValidationError, s_("UpdateProject|Could not set the default branch")
diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb
index 9dd0c9a007a..b4b493624e7 100644
--- a/app/services/releases/base_service.rb
+++ b/app/services/releases/base_service.rb
@@ -5,6 +5,8 @@ module Releases
include BaseServiceUtility
include Gitlab::Utils::StrongMemoize
+ ReleaseProtectedTagAccessError = Class.new(StandardError)
+
attr_accessor :project, :current_user, :params
def initialize(project, user = nil, params = {})
@@ -81,6 +83,15 @@ module Releases
release.execute_hooks(action)
end
+ def track_protected_tag_access_error!
+ unless ::Gitlab::UserAccess.new(current_user, container: project).can_create_tag?(tag_name)
+ Gitlab::ErrorTracking.log_exception(
+ ReleaseProtectedTagAccessError.new,
+ project_id: project.id,
+ user_id: current_user.id)
+ end
+ end
+
# overridden in EE
def project_group_id; end
end
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index 1096e207e02..2aac5644b84 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -7,6 +7,8 @@ module Releases
return error('Release already exists', 409) if release
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
+ track_protected_tag_access_error!
+
# should be found before the creation of new tag
# because tag creation can spawn new pipeline
# which won't have any data for evidence yet
@@ -42,7 +44,13 @@ module Releases
end
def allowed?
- Ability.allowed?(current_user, :create_release, project)
+ Ability.allowed?(current_user, :create_release, project) && can_create_tag?
+ end
+
+ def can_create_tag?
+ return true unless ::Feature.enabled?(:evalute_protected_tag_for_release_permissions, project, default_enabled: :yaml)
+
+ ::Gitlab::UserAccess.new(current_user, container: project).can_create_tag?(tag_name)
end
def create_release(tag, evidence_pipeline)
diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb
index 8abf9308689..36cf29c955d 100644
--- a/app/services/releases/destroy_service.rb
+++ b/app/services/releases/destroy_service.rb
@@ -6,6 +6,8 @@ module Releases
return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed?
+ track_protected_tag_access_error!
+
if release.destroy
success(tag: existing_tag, release: release)
else
diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb
index 4e78120ac05..eda4b7102c0 100644
--- a/app/services/releases/update_service.rb
+++ b/app/services/releases/update_service.rb
@@ -7,6 +7,8 @@ module Releases
return error
end
+ track_protected_tag_access_error!
+
if param_for_milestone_titles_provided?
previous_milestones = release.milestones.map(&:title)
params[:milestones] = milestones
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index 6ff8767a525..34aa414de8f 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -16,11 +16,12 @@ module ResourceAccessTokens
return error(user.errors.full_messages.to_sentence) unless user.persisted?
- member = create_membership(resource, user)
+ access_level = params[:access_level] || Gitlab::Access::MAINTAINER
+ member = create_membership(resource, user, access_level)
unless member.persisted?
delete_failed_user(user)
- return error("Could not provision maintainer access to project access token")
+ return error("Could not provision #{Gitlab::Access.human_access(access_level).downcase} access to project access token")
end
token_response = create_personal_access_token(user)
@@ -102,8 +103,8 @@ module ResourceAccessTokens
Gitlab::Auth.resource_bot_scopes
end
- def create_membership(resource, user)
- resource.add_user(user, :maintainer, expires_at: params[:expires_at])
+ def create_membership(resource, user, access_level)
+ resource.add_user(user, access_level, expires_at: params[:expires_at])
end
def log_event(token)
diff --git a/app/services/security/ci_configuration/dependency_scanning_create_service.rb b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
new file mode 100644
index 00000000000..71e8d5025ae
--- /dev/null
+++ b/app/services/security/ci_configuration/dependency_scanning_create_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Security
+ module CiConfiguration
+ class DependencyScanningCreateService < ::Security::CiConfiguration::BaseCreateService
+ private
+
+ def action
+ Security::CiConfiguration::DependencyScanningBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate
+ end
+
+ def next_branch
+ 'set-dependency-scanning-config'
+ end
+
+ def message
+ _('Configure Dependency Scanning in `.gitlab-ci.yml`, creating this file if it does not already exist')
+ end
+
+ def description
+ _('Configure Dependency Scanning in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings) to customize Dependency Scanning settings.')
+ end
+ end
+ end
+end
diff --git a/app/services/service_ping/build_payload_service.rb b/app/services/service_ping/build_payload_service.rb
new file mode 100644
index 00000000000..2bef3d32103
--- /dev/null
+++ b/app/services/service_ping/build_payload_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ServicePing
+ class BuildPayloadService
+ def execute
+ return {} unless allowed_to_report?
+
+ raw_payload
+ end
+
+ private
+
+ def allowed_to_report?
+ product_intelligence_enabled? && !User.single_user&.requires_usage_stats_consent?
+ end
+
+ def product_intelligence_enabled?
+ ::Gitlab::CurrentSettings.usage_ping_enabled?
+ end
+
+ def raw_payload
+ @raw_payload ||= ::Gitlab::UsageData.data(force_refresh: true)
+ end
+ end
+end
+
+ServicePing::BuildPayloadService.prepend_mod_with('ServicePing::BuildPayloadService')
diff --git a/app/services/service_ping/permit_data_categories_service.rb b/app/services/service_ping/permit_data_categories_service.rb
new file mode 100644
index 00000000000..ff48c022b56
--- /dev/null
+++ b/app/services/service_ping/permit_data_categories_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module ServicePing
+ class PermitDataCategoriesService
+ STANDARD_CATEGORY = 'Standard'
+ SUBSCRIPTION_CATEGORY = 'Subscription'
+ OPERATIONAL_CATEGORY = 'Operational'
+ OPTIONAL_CATEGORY = 'Optional'
+ CATEGORIES = [
+ STANDARD_CATEGORY,
+ SUBSCRIPTION_CATEGORY,
+ OPERATIONAL_CATEGORY,
+ OPTIONAL_CATEGORY
+ ].to_set.freeze
+
+ def execute
+ return [] unless product_intelligence_enabled?
+
+ CATEGORIES
+ end
+
+ def product_intelligence_enabled?
+ pings_enabled? && !User.single_user&.requires_usage_stats_consent?
+ end
+
+ private
+
+ def pings_enabled?
+ ::Gitlab::CurrentSettings.usage_ping_enabled?
+ end
+ end
+end
+
+ServicePing::PermitDataCategoriesService.prepend_mod_with('ServicePing::PermitDataCategoriesService')
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
new file mode 100644
index 00000000000..5c03aa46e18
--- /dev/null
+++ b/app/services/service_ping/submit_service.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module ServicePing
+ class SubmitService
+ PRODUCTION_URL = 'https://version.gitlab.com/usage_data'
+ STAGING_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org/usage_data'
+
+ METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
+ percentage_notes leader_milestones instance_milestones percentage_milestones
+ leader_boards instance_boards percentage_boards leader_merge_requests
+ instance_merge_requests percentage_merge_requests leader_ci_pipelines
+ instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
+ percentage_environments leader_deployments instance_deployments percentage_deployments
+ leader_projects_prometheus_active instance_projects_prometheus_active
+ percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
+ percentage_service_desk_issues].freeze
+
+ SubmissionError = Class.new(StandardError)
+
+ def execute
+ return unless ServicePing::PermitDataCategoriesService.new.product_intelligence_enabled?
+
+ begin
+ usage_data = BuildPayloadService.new.execute
+ raw_usage_data, response = submit_usage_data_payload(usage_data)
+ rescue StandardError
+ return unless Gitlab::CurrentSettings.usage_ping_enabled?
+
+ usage_data = Gitlab::UsageData.data(force_refresh: true)
+ raw_usage_data, response = submit_usage_data_payload(usage_data)
+ end
+
+ version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
+
+ unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0
+ raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}"
+ end
+
+ raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
+
+ store_metrics(response)
+ end
+
+ private
+
+ def submit_payload(usage_data)
+ Gitlab::HTTP.post(
+ url,
+ body: usage_data.to_json,
+ allow_local_requests: true,
+ headers: { 'Content-type' => 'application/json' }
+ )
+ end
+
+ def submit_usage_data_payload(usage_data)
+ raise SubmissionError, 'Usage data is blank' if usage_data.blank?
+
+ raw_usage_data = save_raw_usage_data(usage_data)
+
+ response = submit_payload(usage_data)
+
+ raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success?
+
+ [raw_usage_data, response]
+ end
+
+ def save_raw_usage_data(usage_data)
+ RawUsageData.safe_find_or_create_by(recorded_at: usage_data[:recorded_at]) do |record|
+ record.payload = usage_data
+ end
+ end
+
+ def store_metrics(response)
+ metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com
+
+ return unless metrics.present?
+
+ DevOpsReport::Metric.create!(
+ metrics.slice(*METRICS)
+ )
+ end
+
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details
+ def url
+ if Rails.env.production?
+ PRODUCTION_URL
+ else
+ STAGING_URL
+ end
+ end
+ end
+end
+
+ServicePing::SubmitService.prepend_mod
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 8f1b481d307..6d3b63de9fd 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -2,12 +2,14 @@
module Snippets
class CreateService < Snippets::BaseService
- def execute
- # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
- disable_spam_action_service = params.delete(:disable_spam_action_service) == true
- @request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
+ # NOTE: For Issues::CreateService, we require the spam_params and do not default it to nil, because
+ # spam_checking is likely to be necessary.
+ def initialize(project:, current_user: nil, params: {}, spam_params:)
+ super(project: project, current_user: current_user, params: params)
+ @spam_params = spam_params
+ end
+ def execute
@snippet = build_from_params
return invalid_params_error(@snippet) unless valid_params?
@@ -18,17 +20,15 @@ module Snippets
@snippet.author = current_user
- unless disable_spam_action_service
- Spam::SpamActionService.new(
- spammable: @snippet,
- request: request,
- user: current_user,
- action: :create
- ).execute(spam_params: spam_params)
- end
+ Spam::SpamActionService.new(
+ spammable: @snippet,
+ spam_params: spam_params,
+ user: current_user,
+ action: :create
+ ).execute
if save_and_commit
- UserAgentDetailService.new(@snippet, request).create
+ UserAgentDetailService.new(spammable: @snippet, spam_params: spam_params).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create)
move_temporary_files
@@ -41,7 +41,7 @@ module Snippets
private
- attr_reader :snippet, :request, :spam_params
+ attr_reader :snippet, :spam_params
def build_from_params
if project
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 8571bc9c869..d83b21271c0 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -6,12 +6,15 @@ module Snippets
UpdateError = Class.new(StandardError)
- def execute(snippet)
- # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed.
- disable_spam_action_service = params.delete(:disable_spam_action_service) == true
- @request = params.delete(:request)
- @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request)
+ # NOTE: For Snippets::UpdateService, we default the spam_params to nil, because spam_checking is not
+ # necessary in many cases, and we don't want every caller to have to explicitly pass it as nil
+ # to disable spam checking.
+ def initialize(project:, current_user: nil, params: {}, spam_params: nil)
+ super(project: project, current_user: current_user, params: params)
+ @spam_params = spam_params
+ end
+ def execute(snippet)
return invalid_params_error(snippet) unless valid_params?
if visibility_changed?(snippet) && !visibility_allowed?(visibility_level)
@@ -20,14 +23,12 @@ module Snippets
update_snippet_attributes(snippet)
- unless disable_spam_action_service
- Spam::SpamActionService.new(
- spammable: snippet,
- request: request,
- user: current_user,
- action: :update
- ).execute(spam_params: spam_params)
- end
+ Spam::SpamActionService.new(
+ spammable: snippet,
+ spam_params: spam_params,
+ user: current_user,
+ action: :update
+ ).execute
if save_and_commit(snippet)
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
@@ -40,7 +41,7 @@ module Snippets
private
- attr_reader :request, :spam_params
+ attr_reader :spam_params
def visibility_changed?(snippet)
visibility_level && visibility_level.to_i != snippet.visibility_level
diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb
index e9843497dd7..d31b904f549 100644
--- a/app/services/spam/akismet_service.rb
+++ b/app/services/spam/akismet_service.rb
@@ -20,6 +20,7 @@ module Spam
created_at: DateTime.current,
author: owner_name,
author_email: owner_email,
+ # NOTE: The akismet_client needs the option to be named `:referrer`, not `:referer`
referrer: options[:referer]
}
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 3ae5111b994..ec16ce19cf6 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -4,67 +4,22 @@ module Spam
class SpamActionService
include SpamConstants
- ##
- # Utility method to filter SpamParams from parameters, which will later be passed to #execute
- # after the spammable is created/updated based on the remaining parameters.
- #
- # Takes a hash of parameters from an incoming request to modify a model (via a controller,
- # service, or GraphQL mutation). The parameters will either be camelCase (if they are
- # received directly via controller params) or underscore_case (if they have come from
- # a GraphQL mutation which has converted them to underscore), or in the
- # headers when using the header based flow.
- #
- # Deletes the parameters which are related to spam and captcha processing, and returns
- # them in a SpamParams parameters object. See:
- # https://refactoring.com/catalog/introduceParameterObject.html
- def self.filter_spam_params!(params, request)
- # NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future
- # alternative captcha implementations such as FriendlyCaptcha. See
- # https://gitlab.com/gitlab-org/gitlab/-/issues/273480
- headers = request&.headers || {}
- api = params.delete(:api)
- captcha_response = read_parameter(:captcha_response, params, headers)
- spam_log_id = read_parameter(:spam_log_id, params, headers)&.to_i
-
- SpamParams.new(api: api, captcha_response: captcha_response, spam_log_id: spam_log_id)
- end
-
- def self.read_parameter(name, params, headers)
- [
- params.delete(name),
- params.delete(name.to_s.camelize(:lower).to_sym),
- headers["X-GitLab-#{name.to_s.titlecase(keep_id_suffix: true).tr(' ', '-')}"]
- ].compact.first
- end
-
- attr_accessor :target, :request, :options
- attr_reader :spam_log
-
- def initialize(spammable:, request:, user:, action:)
+ def initialize(spammable:, spam_params:, user:, action:)
@target = spammable
- @request = request
+ @spam_params = spam_params
@user = user
@action = action
- @options = {}
end
# rubocop:disable Metrics/AbcSize
- def execute(spam_params:)
- if request
- options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s
- options[:user_agent] = request.env['HTTP_USER_AGENT']
- options[:referer] = request.env['HTTP_REFERER']
- else
- # TODO: This code is never used, because we do not perform a verification if there is not a
- # request. Why? Should it be deleted? Or should we check even if there is no request?
- options[:ip_address] = target.ip_address
- options[:user_agent] = target.user_agent
- end
+ def execute
+ # If spam_params is passed as `nil`, no check will be performed. This is the easiest way to allow
+ # composed services which may not need to do spam checking to "opt out". For example, when
+ # MoveService is calling CreateService, spam checking is not necessary, as no new content is
+ # being created.
+ return ServiceResponse.success(message: 'Skipped spam check because spam_params was not present') unless spam_params
- recaptcha_verified = Captcha::CaptchaVerificationService.new.execute(
- captcha_response: spam_params.captcha_response,
- request: request
- )
+ recaptcha_verified = Captcha::CaptchaVerificationService.new(spam_params: spam_params).execute
if recaptcha_verified
# If it's a request which is already verified through CAPTCHA,
@@ -73,10 +28,9 @@ module Spam
ServiceResponse.success(message: "CAPTCHA successfully verified")
else
return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
- return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request
return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?
- perform_spam_service_check(spam_params.api)
+ perform_spam_service_check
ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
end
end
@@ -86,7 +40,7 @@ module Spam
private
- attr_reader :user, :action
+ attr_reader :user, :action, :target, :spam_params, :spam_log
##
# In order to be proceed to the spam check process, the target must be
@@ -104,7 +58,7 @@ module Spam
##
# Performs the spam check using the spam verdict service, and modifies the target model
# accordingly based on the result.
- def perform_spam_service_check(api)
+ def perform_spam_service_check
ensure_target_is_dirty
# since we can check for spam, and recaptcha is not verified,
@@ -113,7 +67,7 @@ module Spam
case result
when CONDITIONAL_ALLOW
# at the moment, this means "ask for reCAPTCHA"
- create_spam_log(api)
+ create_spam_log
break if target.allow_possible_spam?
@@ -122,12 +76,12 @@ module Spam
# TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService`
# https://gitlab.com/gitlab-org/gitlab/-/issues/214739
target.spam! unless target.allow_possible_spam?
- create_spam_log(api)
+ create_spam_log
when BLOCK_USER
# TODO: improve BLOCK_USER handling, non-existent until now
# https://gitlab.com/gitlab-org/gitlab/-/issues/329666
target.spam! unless target.allow_possible_spam?
- create_spam_log(api)
+ create_spam_log
when ALLOW
target.clear_spam_flags!
when NOOP
@@ -137,16 +91,21 @@ module Spam
end
end
- def create_spam_log(api)
+ def create_spam_log
@spam_log = SpamLog.create!(
{
user_id: target.author_id,
title: target.spam_title,
description: target.spam_description,
- source_ip: options[:ip_address],
- user_agent: options[:user_agent],
+ source_ip: spam_params.ip_address,
+ user_agent: spam_params.user_agent,
noteable_type: noteable_type,
- via_api: api
+ # Now, all requests are via the API, so hardcode it to true to simplify the logic and API
+ # of this service. See https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/2266
+ # for original introduction of `via_api` field.
+ # See discussion here about possibly deprecating this field:
+ # https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/2266#note_542527450
+ via_api: true
}
)
@@ -159,9 +118,14 @@ module Spam
target_type: noteable_type
}
+ options = {
+ ip_address: spam_params.ip_address,
+ user_agent: spam_params.user_agent,
+ referer: spam_params.referer
+ }
+
SpamVerdictService.new(target: target,
user: user,
- request: request,
options: options,
context: context)
end
diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb
index 3420748822d..ccc17a42f01 100644
--- a/app/services/spam/spam_params.rb
+++ b/app/services/spam/spam_params.rb
@@ -3,30 +3,54 @@
module Spam
##
# This class is a Parameter Object (https://refactoring.com/catalog/introduceParameterObject.html)
- # which acts as an container abstraction for multiple parameter values related to spam and
- # captcha processing for a request.
+ # which acts as an container abstraction for multiple values related to spam and
+ # captcha processing for a provided HTTP request object.
+ #
+ # It is used to encapsulate these values and allow them to be passed from the Controller/GraphQL
+ # layers down into to the Service layer, without needing to pass the entire request and therefore
+ # unnecessarily couple the Service layer to the HTTP request.
#
# Values contained are:
#
- # api: A boolean flag indicating if the request was submitted via the REST or GraphQL API
# captcha_response: The response resulting from the user solving a captcha. Currently it is
# a scalar reCAPTCHA response string, but it can be expanded to an object in the future to
- # support other captcha implementations such as FriendlyCaptcha.
- # spam_log_id: The id of a SpamLog record.
+ # support other captcha implementations such as FriendlyCaptcha. Obtained from
+ # request.headers['X-GitLab-Captcha-Response']
+ # spam_log_id: The id of a SpamLog record. Obtained from request.headers['X-GitLab-Spam-Log-Id']
+ # ip_address = The remote IP. Obtained from request.env['action_dispatch.remote_ip']
+ # user_agent = The user agent. Obtained from request.env['HTTP_USER_AGENT']
+ # referer = The HTTP referer. Obtained from request.env['HTTP_REFERER']
+ #
+ # NOTE: The presence of these values in the request is not currently enforced. If they are missing,
+ # then the spam check may fail, or the SpamLog or UserAgentDetail may have missing fields.
class SpamParams
- attr_reader :api, :captcha_response, :spam_log_id
+ def self.new_from_request(request:)
+ self.new(
+ captcha_response: request.headers['X-GitLab-Captcha-Response'],
+ spam_log_id: request.headers['X-GitLab-Spam-Log-Id'],
+ ip_address: request.env['action_dispatch.remote_ip'].to_s,
+ user_agent: request.env['HTTP_USER_AGENT'],
+ referer: request.env['HTTP_REFERER']
+ )
+ end
+
+ attr_reader :captcha_response, :spam_log_id, :ip_address, :user_agent, :referer
- def initialize(api:, captcha_response:, spam_log_id:)
- @api = api.present?
+ def initialize(captcha_response:, spam_log_id:, ip_address:, user_agent:, referer:)
@captcha_response = captcha_response
@spam_log_id = spam_log_id
+ @ip_address = ip_address
+ @user_agent = user_agent
+ @referer = referer
end
def ==(other)
other.class <= self.class &&
- other.api == api &&
other.captcha_response == captcha_response &&
- other.spam_log_id == spam_log_id
+ other.spam_log_id == spam_log_id &&
+ other.ip_address == ip_address &&
+ other.user_agent == user_agent &&
+ other.referer == referer
end
end
end
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 7155017b73f..8d995631db6 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -5,9 +5,8 @@ module Spam
include AkismetMethods
include SpamConstants
- def initialize(user:, target:, request:, options:, context: {})
+ def initialize(user:, target:, options:, context: {})
@target = target
- @request = request
@user = user
@options = options
@context = context
@@ -59,7 +58,7 @@ module Spam
private
- attr_reader :user, :target, :request, :options, :context
+ attr_reader :user, :target, :options, :context
def akismet_verdict
if akismet.spam?
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
deleted file mode 100644
index 4942dd0e913..00000000000
--- a/app/services/submit_usage_ping_service.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-# frozen_string_literal: true
-
-class SubmitUsagePingService
- PRODUCTION_URL = 'https://version.gitlab.com/usage_data'
- STAGING_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org/usage_data'
-
- METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
- percentage_notes leader_milestones instance_milestones percentage_milestones
- leader_boards instance_boards percentage_boards leader_merge_requests
- instance_merge_requests percentage_merge_requests leader_ci_pipelines
- instance_ci_pipelines percentage_ci_pipelines leader_environments instance_environments
- percentage_environments leader_deployments instance_deployments percentage_deployments
- leader_projects_prometheus_active instance_projects_prometheus_active
- percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues
- percentage_service_desk_issues].freeze
-
- SubmissionError = Class.new(StandardError)
-
- def execute
- return unless Gitlab::CurrentSettings.usage_ping_enabled?
- return if User.single_user&.requires_usage_stats_consent?
-
- usage_data = Gitlab::UsageData.data(force_refresh: true)
-
- raise SubmissionError, 'Usage data is blank' if usage_data.blank?
-
- raw_usage_data = save_raw_usage_data(usage_data)
-
- response = Gitlab::HTTP.post(
- url,
- body: usage_data.to_json,
- allow_local_requests: true,
- headers: { 'Content-type' => 'application/json' }
- )
-
- raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success?
-
- version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id')
-
- unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0
- raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}"
- end
-
- raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id)
-
- store_metrics(response)
- end
-
- private
-
- def save_raw_usage_data(usage_data)
- RawUsageData.safe_find_or_create_by(recorded_at: usage_data[:recorded_at]) do |record|
- record.payload = usage_data
- end
- end
-
- def store_metrics(response)
- metrics = response['conv_index'] || response['dev_ops_score'] # leaving dev_ops_score here, as the response data comes from the gitlab-version-com
-
- return unless metrics.present?
-
- DevOpsReport::Metric.create!(
- metrics.slice(*METRICS)
- )
- end
-
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details
- def url
- if Rails.env.production?
- PRODUCTION_URL
- else
- STAGING_URL
- end
- end
-end
-
-SubmitUsagePingService.prepend_mod
diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb
index 9302c86d3e6..01a98a15869 100644
--- a/app/services/user_agent_detail_service.rb
+++ b/app/services/user_agent_detail_service.rb
@@ -1,16 +1,21 @@
# frozen_string_literal: true
class UserAgentDetailService
- attr_accessor :spammable, :request
-
- def initialize(spammable, request)
+ def initialize(spammable:, spam_params:)
@spammable = spammable
- @request = request
+ @spam_params = spam_params
end
def create
- return unless request
+ unless spam_params&.user_agent && spam_params&.ip_address
+ messasge = 'Skipped UserAgentDetail creation because necessary spam_params were not provided'
+ return ServiceResponse.success(message: messasge)
+ end
- spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s)
+ spammable.create_user_agent_detail(user_agent: spam_params.user_agent, ip_address: spam_params.ip_address)
end
+
+ private
+
+ attr_reader :spammable, :spam_params
end
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index f52502e0379..5f48f410bf7 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -20,8 +20,13 @@ class UserProjectAccessChangedService
if priority == HIGH_PRIORITY
AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
else
- AuthorizedProjectUpdate::UserRefreshFromReplicaWorker.bulk_perform_in( # rubocop:disable Scalability/BulkPerformWithContext
- DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds)
+ with_related_class_context do
+ # We wrap the execution in `with_related_class_context`so as to obtain
+ # the location of the original caller
+ # in jobs enqueued from within `AuthorizedProjectUpdate::UserRefreshFromReplicaWorker`
+ AuthorizedProjectUpdate::UserRefreshFromReplicaWorker.bulk_perform_in( # rubocop:disable Scalability/BulkPerformWithContext
+ DELAY, bulk_args, batch_size: 100, batch_delay: 30.seconds)
+ end
end
end
@@ -29,4 +34,11 @@ class UserProjectAccessChangedService
result
end
+
+ private
+
+ def with_related_class_context(&block)
+ current_caller_id = Gitlab::ApplicationContext.current_context_attribute('meta.caller_id').presence
+ Gitlab::ApplicationContext.with_context(related_class: current_caller_id, &block)
+ end
end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 1d5b38575bb..79bdf34392f 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -177,7 +177,6 @@ class WebHookService
end
def rate_limited?
- return false unless Feature.enabled?(:web_hooks_rate_limit, default_enabled: :yaml)
return false if rate_limit.nil?
Gitlab::ApplicationRateLimiter.throttled?(
diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb
index 4ec884469eb..891e18c0acc 100644
--- a/app/services/wiki_pages/base_service.rb
+++ b/app/services/wiki_pages/base_service.rb
@@ -12,7 +12,7 @@ module WikiPages
def execute_hooks(page)
page_data = payload(page)
container.execute_hooks(page_data, :wiki_page_hooks)
- container.execute_services(page_data, :wiki_page_hooks)
+ container.execute_integrations(page_data, :wiki_page_hooks)
increment_usage
create_wiki_event(page)
end
diff --git a/app/services/wiki_pages/create_service.rb b/app/services/wiki_pages/create_service.rb
index 9702876effa..d14d94d77df 100644
--- a/app/services/wiki_pages/create_service.rb
+++ b/app/services/wiki_pages/create_service.rb
@@ -6,11 +6,12 @@ module WikiPages
wiki = Wiki.for_container(container, current_user)
page = WikiPage.new(wiki)
- if page.create(@params)
- execute_hooks(page)
+ wiki.capture_git_error(event_action) do
+ page.create(@params)
end
if page.persisted?
+ execute_hooks(page)
ServiceResponse.success(payload: { page: page })
else
ServiceResponse.error(message: _('Could not create wiki page'), payload: { page: page })
diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb
index 88275f8c417..12b2cf87d5d 100644
--- a/app/services/wiki_pages/update_service.rb
+++ b/app/services/wiki_pages/update_service.rb
@@ -8,7 +8,7 @@ module WikiPages
# this class is not thread safe!
@old_slug = page.slug
- if page.update(@params)
+ if page.wiki.capture_git_error(event_action) { page.update(@params) }
execute_hooks(page)
ServiceResponse.success(payload: { page: page })
else
diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb
index 82179459345..88a593cce48 100644
--- a/app/services/wikis/create_attachment_service.rb
+++ b/app/services/wikis/create_attachment_service.rb
@@ -21,7 +21,11 @@ module Wikis
end
def create_commit!
+ wiki.create_wiki_repository
+
commit_result(create_transformed_commit(@file_content))
+ rescue Wiki::CouldNotCreateWikiError
+ raise_error("Error creating the wiki repository")
end
private
diff --git a/app/uploaders/dependency_proxy/file_uploader.rb b/app/uploaders/dependency_proxy/file_uploader.rb
index c46539bafaa..5154f180454 100644
--- a/app/uploaders/dependency_proxy/file_uploader.rb
+++ b/app/uploaders/dependency_proxy/file_uploader.rb
@@ -24,7 +24,7 @@ class DependencyProxy::FileUploader < GitlabUploader
# so we must store the custom content type in object storage.
# This does not apply to DependencyProxy::Blob uploads.
def set_content_type(file)
- return unless model.class == DependencyProxy::Manifest
+ return unless model.instance_of?(DependencyProxy::Manifest)
file.content_type = model.content_type
end
diff --git a/app/validators/json_schemas/build_metadata_secrets.json b/app/validators/json_schemas/build_metadata_secrets.json
index 799e7ab1642..3c8035d0dcf 100644
--- a/app/validators/json_schemas/build_metadata_secrets.json
+++ b/app/validators/json_schemas/build_metadata_secrets.json
@@ -23,7 +23,8 @@
}
},
"additionalProperties": false
- }
+ },
+ "^file$": { "type": "boolean" }
},
"additionalProperties": false
}
diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json
new file mode 100644
index 00000000000..19abde7de08
--- /dev/null
+++ b/app/validators/json_schemas/error_tracking_event_payload.json
@@ -0,0 +1,231 @@
+{
+ "description": "Error tracking event payload",
+ "type": "object",
+ "required": [],
+ "modules": {
+ "type": "object"
+ },
+ "properties": {
+ "event_id": {
+ "type": "string"
+ },
+ "level": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "release": {
+ "type": "string"
+ },
+ "environment": {
+ "type": "string"
+ },
+ "server_name": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "user": {
+ "type": "object",
+ "required": [],
+ "properties": {}
+ },
+ "tags": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "request_id": {
+ "type": "string"
+ }
+ }
+ },
+ "contexts": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "os": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ },
+ "build": {
+ "type": "string"
+ },
+ "kernel_version": {
+ "type": "string"
+ }
+ }
+ },
+ "runtime": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ },
+ "trace": {
+ "type": "object"
+ }
+ }
+ },
+ "fingerprint": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "breadcrumbs": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "values": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "category": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object"
+ },
+ "message": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "number"
+ }
+ }
+ }
+ }
+ }
+ },
+ "transaction": {
+ "type": "string"
+ },
+ "platform": {
+ "type": "string"
+ },
+ "sdk": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ },
+ "request": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "url": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string"
+ },
+ "headers": {
+ "type": "object"
+ },
+ "env": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "SERVER_NAME": {
+ "type": "string"
+ },
+ "SERVER_PORT": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "exception": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "values": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "value": {
+ "type": "string"
+ },
+ "module": {
+ "type": "string"
+ },
+ "thread_id": {
+ "type": "number"
+ },
+ "stacktrace": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "frames": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "project_root": {
+ "type": "string"
+ },
+ "abs_path": {
+ "type": "string"
+ },
+ "function": {
+ "type": "string"
+ },
+ "lineno": {
+ "type": "number"
+ },
+ "in_app": {
+ "type": "boolean"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "pre_context": {
+ "type": "array"
+ },
+ "context_line": {
+ "type": "string"
+ },
+ "post_context": {
+ "type": "array"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 5ae45d5a9da..fb530e18b03 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -64,6 +64,6 @@
= f.text_field :default_ci_config_path, class: 'form-control gl-form-input', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
= _("The default CI/CD configuration file and path for new projects.").html_safe
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-file'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
index b28a53d8bf6..ade6dac606a 100644
--- a/app/views/admin/application_settings/_gitaly.html.haml
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -3,25 +3,19 @@
%fieldset
.form-group
- = f.label :gitaly_timeout_default, _('Default Timeout Period'), class: 'label-bold'
+ = f.label :gitaly_timeout_default, _('Default timeout'), class: 'label-bold'
= f.number_field :gitaly_timeout_default, class: 'form-control gl-form-input'
.form-text.text-muted
- Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
- for git fetch/push operations or Sidekiq jobs.
- This timeout should be less than the worker timeout. If a Gitaly call timeout would exceed the
- worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate
- the worker.
+ = _('Timeout for most Gitaly operations (in seconds).')
.form-group
- = f.label :gitaly_timeout_fast, _('Fast Timeout Period'), class: 'label-bold'
+ = f.label :gitaly_timeout_fast, _('Fast timeout'), class: 'label-bold'
= f.number_field :gitaly_timeout_fast, class: 'form-control gl-form-input'
.form-text.text-muted
- Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
- If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
- can help maintain the stability of the GitLab instance.
+ = _('Timeout for the fastest Gitaly operations (in seconds).')
.form-group
- = f.label :gitaly_timeout_medium, _('Medium Timeout Period'), class: 'label-bold'
+ = f.label :gitaly_timeout_medium, _('Medium timeout'), class: 'label-bold'
= f.number_field :gitaly_timeout_medium, class: 'form-control gl-form-input'
.form-text.text-muted
- Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
+ = _('Timeout for moderately fast Gitaly operations (in seconds). Provide a value between Default timeout and Fast timeout.')
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index e7816f5a1c0..b71e8ca831e 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -5,18 +5,18 @@
= render_if_exists 'admin/application_settings/help_text_setting', form: f
.form-group
- = f.label :help_page_text, class: 'label-bold'
+ = f.label :help_page_text, _('Additional text to show on the Help page'), class: 'label-bold'
= f.text_area :help_page_text, class: 'form-control gl-form-input', rows: 4
.form-text.text-muted= _('Markdown enabled')
.form-group
.form-check
= f.check_box :help_page_hide_commercial_content, class: 'form-check-input'
= f.label :help_page_hide_commercial_content, class: 'form-check-label' do
- = _('Hide marketing-related entries from help')
+ = _('Hide marketing-related entries from the Help page.')
.form-group
= f.label :help_page_support_url, _('Support page URL'), class: 'label-bold'
= f.text_field :help_page_support_url, class: 'form-control gl-form-input', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
- %span.form-text.text-muted#support_help_block= _('Alternate support URL for help page and help dropdown')
+ %span.form-text.text-muted#support_help_block= _('Alternate support URL for Help page and Help dropdown')
- if show_documentation_base_url_field?
.form-group
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml
index f881808e51f..34c40892467 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -8,6 +8,6 @@
= f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
= f.text_field :default_branch_name, placeholder: Gitlab::DefaultBranch.value, class: 'form-control gl-form-input'
%span.form-text.text-muted
- = (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe
+ = (s_("AdminSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name } ).html_safe
= f.submit _('Save changes'), class: 'gl-button btn-confirm'
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index fdb91937ec3..5c8f3379fce 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -7,6 +7,7 @@
= f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control'
.form-text.text-muted
= _('Default first day of the week in calendars and date pickers.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/index.md', anchor: 'default-first-day-of-the-week'), target: '_blank'
.form-group
= f.label :time_tracking, _('Time tracking'), class: 'label-bold'
@@ -14,5 +15,9 @@
= f.check_box :time_tracking_limit_to_hours, class: 'form-check-input'
= f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
= _('Limit display of time tracking units to hours.')
+ .form-text.text-muted
+ = _('Display time tracking in issues in total hours only.')
+ = link_to _('What is time tracking?'), help_page_path('user/project/time_tracking.md'), target: '_blank'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
new file mode 100644
index 00000000000..6204f7df5dc
--- /dev/null
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -0,0 +1,25 @@
+- return unless Feature.enabled?(:mailgun_events_receiver)
+
+- expanded = integration_expanded?('mailgun_')
+%section.settings.as-mailgun.no-animate#js-mailgun-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Mailgun')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure the %{link} integration.').html_safe % { link: link_to(_('Mailgun events'), 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', target: '_blank') }
+ .settings-content
+ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-mailgun-settings'), html: { class: 'fieldset-form', id: 'mailgun-settings' } do |f|
+ = form_errors(@application_setting) if expanded
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :mailgun_events_enabled, class: 'form-check-input'
+ = f.label :mailgun_events_enabled, _('Enable Mailgun event receiver'), class: 'form-check-label'
+ .form-group
+ = f.label :mailgun_signing_key, _('Mailgun HTTP webhook signing key'), class: 'label-light'
+ = f.text_field :mailgun_signing_key, class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index 31576d54a04..6df1be9f6cb 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -8,15 +8,14 @@
.form-check
= f.check_box :repository_checks_enabled, class: 'form-check-input'
= f.label :repository_checks_enabled, class: 'form-check-label' do
- = _("Enable Repository Checks")
+ = _("Enable repository checks")
.form-text.text-muted
- - link_to_git_fsck = link_to('git fsck', 'https://git-scm.com/docs/git-fsck', target: '_blank')
- = _("GitLab will periodically run %{link_to_git_fsck} in all project and wiki repositories to look for silent disk corruption issues.").html_safe % { link_to_git_fsck: link_to_git_fsck }
+ = html_escape(s_('Run %{code_start}git fsck%{code_end} periodically in all project and wiki repositories to look for silent disk corruption issues.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
.form-group
.form-text.text-muted
- = _("If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.")
+ = _("If you get a lot of false alarms from repository checks, you can clear all repository check information from the database.")
- clear_repository_checks_link = _('Clear all repository checks')
- - clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?')
+ - clear_repository_checks_message = _('This clears repository check states for all projects in the database and cannot be undone. Are you sure?')
= link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3"
.sub-section
@@ -25,29 +24,31 @@
.form-check
= f.check_box :housekeeping_enabled, class: 'form-check-input'
= f.label :housekeeping_enabled, class: 'form-check-label' do
- = _("Enable automatic repository housekeeping (git repack, git gc)")
+ = _("Enable automatic repository housekeeping")
.form-text.text-muted
- = _("If you keep automatic housekeeping disabled for a long time Git repository access on your GitLab server will become slower and your repositories will use more disk space. We recommend to always leave this enabled.")
+ = _("Leaving this setting enabled is recommended.")
+ = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
.form-check
= f.check_box :housekeeping_bitmaps_enabled, class: 'form-check-input'
= f.label :housekeeping_bitmaps_enabled, class: 'form-check-label' do
= _("Enable Git pack file bitmap creation")
.form-text.text-muted
- = _("Creating pack file bitmaps makes housekeeping take a little longer but bitmaps should accelerate 'git clone' performance.")
+ = _("Improves Git cloning performance.")
+ = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'housekeeping-options'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-bold'
= f.number_field :housekeeping_incremental_repack_period, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("Number of Git pushes after which an incremental 'git repack' is run.")
+ = html_escape(s_('Number of Git pushes after which an incremental %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
.form-group
= f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-bold'
= f.number_field :housekeeping_full_repack_period, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("Number of Git pushes after which a full 'git repack' is run.")
+ = html_escape(s_('Number of Git pushes after which a full %{code_start}git repack%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
.form-group
= f.label :housekeeping_gc_period, _('Git GC period'), class: 'label-bold'
= f.number_field :housekeeping_gc_period, class: 'form-control gl-form-input'
.form-text.text-muted
- = _("Number of Git pushes after which 'git gc' is run.")
+ = html_escape(s_('Number of Git pushes after which %{code_start}git gc%{code_end} is run.')) % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index a0076a2f75d..0c9b04c02d1 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -3,14 +3,13 @@
%fieldset
.form-group
- = f.label :mirror_available, _('Enable mirror configuration'), class: 'label-bold'
+ = f.label :mirror_available, _('Repository mirroring configuration'), class: 'label-bold'
.form-check
= f.check_box :mirror_available, class: 'form-check-input'
= f.label :mirror_available, class: 'form-check-label' do
- = _('Allow repository mirroring to be configured by project maintainers')
+ = _('Allow project maintainers to configure repository mirroring')
%span.form-text.text-muted
- = _('If disabled, only admins will be able to configure repository mirroring.')
- = link_to sprite_icon('question-o'), help_page_path('user/project/repository/repository_mirroring.md')
+ = _('If disabled, only administrators can configure repository mirroring.')
= render_if_exists 'admin/application_settings/mirror_settings', form: f
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index ab1b2bab573..62a90e173ec 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -3,20 +3,24 @@
%fieldset
.sub-section
- %h4= _("Hashed repository storage paths")
+ %h4= _('Hashed repository storage paths')
.form-group
.form-check
= f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox', disabled: @application_setting.hashed_storage_enabled?
- = f.label :hashed_storage_enabled, _("Use hashed storage"), class: 'label-bold form-check-label'
+ = f.label :hashed_storage_enabled, _('Use hashed storage'), class: 'label-bold form-check-label'
.form-text.text-muted
- = _("Use hashed storage paths for newly created and renamed repositories. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Repository URL changes and may improve disk I/O performance. (Always enabled since 13.0)")
+ = _('Use hashed storage paths for newly created and renamed repositories. Always enabled since 13.0.')
+ = link_to s_('Learn more.'), help_page_path('administration/repository_storage_types.md', anchor: 'hashed-storage'), target: '_blank', rel: 'noopener noreferrer'
+
.sub-section
%h4= _("Storage nodes for new repositories")
.form-group
.form-text
%p.text-secondary
- = _('Enter weights for storages for new repositories.')
- = link_to sprite_icon('question-o'), help_page_path('administration/repository_storage_paths')
+ - weights_link_url = help_page_path('administration/repository_storage_paths.md', anchor: 'configure-where-new-repositories-are-stored')
+ - weights_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: weights_link_url }
+ = html_escape(s_('Enter %{weights_link_start}weights%{weights_link_end} for storages for new repositories. Configured storages appear below.')) % { weights_link_start: weights_link_start, weights_link_end: '</a>'.html_safe }
+ = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer'
.form-check
= f.fields_for :repository_storages_weighted, storage_weights do |storage_form|
- Gitlab.config.repositories.storages.keys.each do |storage|
diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml
new file mode 100644
index 00000000000..b7ab896533b
--- /dev/null
+++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml
@@ -0,0 +1,16 @@
+= form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-runner-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = hidden_field_tag "application_setting[valid_runner_registrars][]", nil
+ - ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES.each do |type|
+ .form-check
+ = f.check_box(:valid_runner_registrars, { multiple: true, checked: valid_runner_registrars.include?(type), class: 'form-check-input' }, type, nil)
+ = f.label :valid_runner_registrars, class: 'form-check-label' do
+ = s_("Runners|Members of the %{type} can register runners") % { type: type }
+ %span.form-text.gl-text-gray-600
+ = _('If no options are selected, only administrators can register runners.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'runner-registration'), target: '_blank', rel: 'noopener noreferrer'
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index 5daf220d81c..8c98778147e 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -6,7 +6,8 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('development/snowplow/index') }
+ = html_escape(_('Configure %{link} to track events. %{link_start}Learn more.%{link_end}')) % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank').html_safe, link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form', id: 'snowplow-settings' } do |f|
= form_errors(@application_setting) if expanded
@@ -15,15 +16,21 @@
.form-group
.form-check
= f.check_box :snowplow_enabled, class: 'form-check-input'
- = f.label :snowplow_enabled, _('Enable snowplow tracking'), class: 'form-check-label'
+ = f.label :snowplow_enabled, _('Enable Snowplow tracking'), class: 'form-check-label'
.form-group
= f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light'
= f.text_field :snowplow_collector_hostname, class: 'form-control gl-form-input', placeholder: 'snowplow.example.com'
+ .form-text.text-muted
+ = _('The hostname of your Snowplow collector.')
.form-group
= f.label :snowplow_app_id, _('App ID'), class: 'label-light'
- = f.text_field :snowplow_app_id, class: 'form-control gl-form-input'
+ = f.text_field :snowplow_app_id, class: 'form-control gl-form-input', placeholder: 'gitlab'
+ .form-text.text-muted
+ = _('The ID of the application.')
.form-group
= f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light'
- = f.text_field :snowplow_cookie_domain, class: 'form-control gl-form-input'
+ = f.text_field :snowplow_cookie_domain, class: 'form-control gl-form-input', placeholder: '.your-gitlab-instance.com'
+ .form-text.text-muted
+ = _('The Snowplow cookie domain.')
= f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml
index 5df2454ed2e..9a34400092e 100644
--- a/app/views/admin/application_settings/_third_party_offers.html.haml
+++ b/app/views/admin/application_settings/_third_party_offers.html.haml
@@ -2,11 +2,11 @@
%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
- = _('Third party offers')
+ = _('Third-party offers')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _('Control the display of third party offers.')
+ = _('Control whether to display third-party offers in GitLab.')
.settings-content
= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form', id: 'third-party-offers-settings' } do |f|
= form_errors(@application_setting) if expanded
@@ -15,6 +15,6 @@
.form-group
.form-check
= f.check_box :hide_third_party_offers, class: 'form-check-input'
- = f.label :hide_third_party_offers, _('Do not display offers from third parties within GitLab'), class: 'form-check-label'
+ = f.label :hide_third_party_offers, _('Do not display offers from third parties'), class: 'form-check-label'
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index 64e8751bf31..ddd0abb4c34 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -1,4 +1,4 @@
-- payload_class = 'js-usage-ping-payload'
+- payload_class = 'js-service-ping-payload'
= form_for @application_setting, url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
@@ -17,23 +17,44 @@
.form-check
= f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input'
= f.label :usage_ping_enabled, class: 'form-check-label' do
- = _('Enable usage ping')
+ = _('Enable service ping')
.form-text.text-muted
- if can_be_configured
%p.mb-2= _('To help improve GitLab and its user experience, GitLab will periodically collect usage information.')
- - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping')
- - usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
- %p.mb-2= s_('%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
+ - service_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'service-ping')
+ - service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_ping_path }
+ %p.mb-2= s_('%{service_ping_link_start}Learn more%{service_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { service_ping_link_start: service_ping_link_start, service_ping_link_end: '</a>'.html_safe }
%button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } }
.gl-spinner.js-spinner.gl-display-none.gl-mr-2
.js-text.d-inline= _('Preview payload')
- %pre.usage-data.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
+ %pre.service-data-payload-container.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
- = _('The usage ping is disabled, and cannot be configured through this form.')
- - deactivating_usage_ping_path = help_page_path('development/usage_ping/index.md', anchor: 'disable-usage-ping')
- - deactivating_usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_usage_ping_path }
- = s_('For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}.').html_safe % { deactivating_usage_ping_link_start: deactivating_usage_ping_link_start, deactivating_usage_ping_link_end: '</a>'.html_safe }
+ = _('Service ping is disabled in your configuration file, and cannot be enabled through this form.')
+ - deactivating_service_ping_path = help_page_path('development/service_ping/index.md', anchor: 'disable-service-ping-using-the-configuration-file')
+ - deactivating_service_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_service_ping_path }
+ = s_('For more information, see the documentation on %{deactivating_service_ping_link_start}deactivating service ping%{deactivating_service_ping_link_end}.').html_safe % { deactivating_service_ping_link_start: deactivating_service_ping_link_start, deactivating_service_ping_link_end: '</a>'.html_safe }
+ .form-group
+ - usage_ping_enabled = @application_setting.usage_ping_enabled?
+ .form-check
+ = f.check_box :usage_ping_features_enabled?, disabled: !usage_ping_enabled, class: 'form-check-input'
+ = f.label :usage_ping_features_enabled?, class: 'form-check-label gl-cursor-not-allowed', id: 'service_ping_features_label' do
+ = _('Enable Registration Features')
+ = link_to sprite_icon('question-o'), help_page_path('development/service_ping/index.md', anchor: 'registration-features-program')
+ .form-text.text-muted
+ - if usage_ping_enabled
+ %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('You can enable Registration Features because Service Ping is enabled. To continue using Registration Features in the future, you will also need to register with GitLab via a new cloud licensing service.')
+ - else
+ %p.gl-mb-3.text-muted{ id: 'service_ping_features_helper_text' }= _('To enable Registration Features, make sure "Enable service ping" is checked.')
+
+ %p.gl-mb-3.text-muted= _('Registration Features include:')
+ .form-text
+ - email_from_gitlab_path = help_page_path('tools/email.md')
+ - link_end = '</a>'.html_safe
+ - email_from_gitlab_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: email_from_gitlab_path }
+ %ul
+ %li
+ = _('Email from GitLab - email users right from the Admin Area. %{link_start}Learn more%{link_end}.').html_safe % { link_start: email_from_gitlab_link, link_end: link_end }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml
index 40486e9a9e6..1298be9a6cb 100644
--- a/app/views/admin/application_settings/ci/_header.html.haml
+++ b/app/views/admin/application_settings/ci/_header.html.haml
@@ -8,7 +8,7 @@
%p
= _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.')
- = link_to s_('Learn more.'), help_page_path('ci/variables/README', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer'
%p
= _('Variables can be:')
%ul
@@ -16,4 +16,4 @@
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
%li
= html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml
index 127ab8ea1f4..18ec43407c3 100644
--- a/app/views/admin/application_settings/ci_cd.html.haml
+++ b/app/views/admin/application_settings/ci_cd.html.haml
@@ -8,7 +8,7 @@
.settings-content
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
= s_('Environment variables on this GitLab instance are configured to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
#js-instance-variables{ data: { endpoint: admin_ci_variables_path, group: 'true', maskable_regex: ci_variable_maskable_regex, protected_by_default: ci_variable_protected_by_default?.to_s} }
@@ -38,3 +38,13 @@
= _('Various container registry settings.')
.settings-content
= render 'registry'
+
+- if Feature.enabled?(:runner_registration_control)
+ %section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = s_('Runners|Runner registration')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? 'Collapse' : 'Expand'
+ .settings-content
+ = render 'runner_registrars_form'
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 0fbbef02613..53bdbcd7137 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -107,6 +107,7 @@
= render_if_exists 'admin/application_settings/maintenance_mode_settings_form'
= render 'admin/application_settings/gitpod'
= render 'admin/application_settings/kroki'
+= render 'admin/application_settings/mailgun'
= render 'admin/application_settings/plantuml'
= render 'admin/application_settings/sourcegraph'
= render_if_exists 'admin/application_settings/slack'
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index 7a81d53c085..d818c587b79 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -1,9 +1,9 @@
-- breadcrumb_title _('Integrations')
-- page_title _('Integrations')
+- breadcrumb_title s_('Integrations|Instance-level integration management')
+- page_title s_('Integrations|Instance-level integration management')
- @content_class = 'limit-container-width' unless fluid_layout
-%h3= s_('Integrations|Project integration management')
+%h3= s_('Integrations|Instance-level integration management')
- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
-%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
+%p= s_("Integrations|GitLab administrators can set up integrations that all groups and projects inherit and use by default. These integrations apply to all groups and projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}instance-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 113ff20e910..14483e4e55e 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -47,10 +47,8 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Enable or disable version check and usage ping.')
+ = _('Enable or disable version check and service ping.')
.settings-content
= render 'usage'
-= render_if_exists 'admin/application_settings/seat_link_setting', expanded: expanded_by_default?
-
= render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded_by_default?
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index 17bf9ba84a2..0dfc3d7a60d 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -20,18 +20,19 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _("Configure What's new drawer and content.")
+ = _("Configure %{italic_start}What's new%{italic_end} drawer and content.").html_safe % { italic_start: '<i>'.html_safe, italic_end: '</i>'.html_safe }
.settings-content
= render 'whats_new'
%section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
- = _('Help page')
+ = _('Sign-in and Help page')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Help page text and support page url.')
+ = _('Additional text for the sign-in and Help page.')
+ = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'help_page'
@@ -60,11 +61,13 @@
%section.settings.as-gitaly.no-animate#js-gitaly-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
- = _('Gitaly')
+ = _('Gitaly timeouts')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Configure Gitaly timeouts.')
+ %span
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'gitaly'
@@ -75,6 +78,6 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Various localization settings.')
+ = _('Configure the default first day of the week and time tracking units.')
.settings-content
= render 'localization'
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index 111cc9c5d7c..2a9fba1aef6 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -9,7 +9,7 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Set the default name of the initial branch when creating new repositories through the user interface.')
+ = s_('AdminSettings|The default name for the initial branch of new repositories created in the instance.')
.settings-content
= render 'initial_branch_name'
@@ -21,6 +21,7 @@
= expanded_by_default? ? 'Collapse' : 'Expand'
%p
= _('Configure repository mirroring.')
+ = link_to s_('Learn more.'), help_page_path('user/project/repository/repository_mirroring.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render partial: 'repository_mirrors_form'
@@ -31,7 +32,8 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure storage path settings.')
+ = _('Configure repository storage.')
+ = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'repository_storage'
@@ -42,7 +44,11 @@
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure automatic git checks and housekeeping on repositories.')
+ - repository_checks_link_url = help_page_path('administration/repository_checks.md')
+ - repository_checks_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_checks_link_url }
+ - housekeeping_link_url = help_page_path('administration/housekeeping.md')
+ - housekeeping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: housekeeping_link_url }
+ = html_escape(s_('Configure %{repository_checks_link_start}repository checks%{link_end} and %{housekeeping_link_start}housekeeping%{link_end} on repositories.')) % { repository_checks_link_start: repository_checks_link_start, housekeeping_link_start: housekeeping_link_start, link_end: '</a>'.html_safe }
.settings-content
= render 'repository_check'
diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml
index 40860ea9400..ddb2eb27705 100644
--- a/app/views/admin/background_migrations/_migration.html.haml
+++ b/app/views/admin/background_migrations/_migration.html.haml
@@ -8,3 +8,12 @@
= _('Unknown')
%td{ role: 'cell', data: { label: _('Status') } }
%span.badge.badge-pill.gl-badge.sm{ class: batched_migration_status_badge_class_name(migration) }= migration.status.humanize
+ %td{ role: 'cell', data: { label: _('Action') } }
+ - if migration.active?
+ = button_to pause_admin_background_migration_path(migration),
+ class: 'gl-button btn btn-icon has-tooltip', title: _('Pause'), 'aria-label' => _('Pause') do
+ = sprite_icon('pause', css_class: 'gl-button-icon gl-icon')
+ - elsif migration.paused?
+ = button_to resume_admin_background_migration_path(migration),
+ class: 'gl-button btn btn-icon has-tooltip', title: _('Resume'), 'aria-label' => _('Resume') do
+ = sprite_icon('play', css_class: 'gl-button-icon gl-icon')
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index 2a372c89912..9ccbdfb5f20 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -29,6 +29,7 @@
%th.table-th-transparent.border-bottom{ role: 'cell' }= _('Migration')
%th.table-th-transparent.border-bottom{ role: 'cell' }= _('Progress')
%th.table-th-transparent.border-bottom{ role: 'cell' }= _('Status')
+ %th.table-th-transparent.border-bottom{ role: 'cell' }
%tbody{ role: 'rowgroup' }
= render partial: 'migration', collection: @migrations
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 58c65bdc8c7..ec3daf6c494 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -3,15 +3,14 @@
- billable_users_url = help_page_path('subscriptions/self_managed/index', anchor: 'billable-users')
- billable_users_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer nofollow">'.html_safe % { url: billable_users_url }
+= render_if_exists 'shared/qrtly_reconciliation_alert'
+
- if @notices
- @notices.each do |notice|
.js-vue-alert{ 'v-cloak': true, data: { variant: notice[:type],
dismissible: true.to_s } }
= notice[:message].html_safe
-- if Gitlab.ee? && display_upcoming_reconciliation_alert?
- #js-qrtly-reconciliation-alert{ data: upcoming_reconciliation_hash }
-
- if @license.present?
.license-panel.gl-mt-5
= render_if_exists 'admin/licenses/summary'
diff --git a/app/views/admin/dev_ops_report/_callout.html.haml b/app/views/admin/dev_ops_report/_callout.html.haml
index f313865478d..2b4c258a00c 100644
--- a/app/views/admin/dev_ops_report/_callout.html.haml
+++ b/app/views/admin/dev_ops_report/_callout.html.haml
@@ -8,6 +8,6 @@
%h4
= _('Introducing Your DevOps Report')
%p
- = _('Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
+ = _('Your DevOps Report gives an overview of how you are using GitLab from a feature perspective. Use it to view how you compare with other organizations.')
.svg-container.devops
= custom_icon('dev_ops_report_overview')
diff --git a/app/views/admin/dev_ops_report/_report.html.haml b/app/views/admin/dev_ops_report/_report.html.haml
index dbd0020e382..0b26548d6e6 100644
--- a/app/views/admin/dev_ops_report/_report.html.haml
+++ b/app/views/admin/dev_ops_report/_report.html.haml
@@ -1,10 +1,9 @@
-- usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
+- service_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
-- if usage_ping_enabled && show_callout?('dev_ops_report_intro_callout_dismissed')
+- if service_ping_enabled && show_callout?('dev_ops_report_intro_callout_dismissed')
= render 'callout'
-- if !usage_ping_enabled
- #js-devops-usage-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/usage_ping/index.md') } }
+- if !service_ping_enabled
+ #js-devops-service-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_service_ping_path: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/service_ping/index.md') } }
- else
#js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json, devops_report_docs_path: help_page_path('user/admin_area/analytics/dev_ops_report'), no_data_image_path: image_path('dev_ops_report_no_data.svg') } }
-
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 5f8ec5086bd..9b42e1b4967 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -128,16 +128,12 @@
.card-header
= html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe }
%span.badge.badge-pill= @group.users_count
- .float-right
- = link_to group_group_members_path(@group), class: 'btn btn-default gl-button btn-sm' do
- = sprite_icon('pencil-square', css_class: 'gl-icon')
- = _('Manage access')
+ = render 'shared/members/manage_access_button', path: group_group_members_path(@group)
%ul.content-list.group-users-list.content-list.members-list
= render partial: 'shared/members/member',
collection: @members, as: :member,
locals: { membership_source: @group,
group: @group,
- show_controls: false,
current_user_is_group_owner: current_user_is_group_owner }
- unless @members.size < Kaminari.config.default_per_page
.card-footer
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index a6d562dad31..d85ab476693 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -15,3 +15,5 @@
= render @identities
- else
%h4= _('This user has no identities')
+
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index ec393fdd794..1609687fc8d 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -19,7 +19,8 @@
path: admin_user_impersonation_tokens_path,
impersonation: true,
token: @impersonation_token,
- scopes: @scopes
+ scopes: @scopes,
+ help_path: help_page_path('api/index', anchor: 'impersonation-tokens')
= render 'shared/access_tokens/table',
type: type,
@@ -27,3 +28,5 @@
impersonation: true,
active_tokens: @active_impersonation_tokens,
revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
+
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 40443fb3406..5c92cbf957e 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -15,8 +15,11 @@
- if @project.last_repository_check_failed?
.row
.col-md-12
- .gl-alert.gl-alert-danger.gl-mb-5{ data: { testid: 'last-repository-check-failed-alert' } }
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render 'shared/global_alert',
+ variant: :danger,
+ alert_class: 'gl-mb-5',
+ alert_data: { testid: 'last-repository-check-failed-alert' },
+ is_container: true do
.gl-alert-body
- last_check_message = _("Last repository check (%{last_check_timestamp}) failed. See the 'repocheck.log' file for error messages.")
- last_check_message = last_check_message % { last_check_timestamp: time_ago_with_tooltip(@project.last_repository_check_at) }
@@ -180,37 +183,29 @@
%strong= @group.name
= _('group members')
%span.badge.badge-pill= @group_members.size
- .float-right
- = link_to admin_group_path(@group), class: 'btn btn-default gl-button btn-sm' do
- = sprite_icon('pencil-square', css_class: 'gl-icon')
- = _('Manage access')
+ = render 'shared/members/manage_access_button', path: group_group_members_path(@group)
%ul.content-list.members-list
= render partial: 'shared/members/member',
collection: @group_members, as: :member,
locals: { membership_source: @project,
group: @group,
- show_controls: false,
current_user_is_group_owner: current_user_is_group_owner }
.card-footer
= paginate @group_members, param_name: 'group_members_page', theme: 'gitlab'
- = render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters, force_mobile_view: true
+ = render 'shared/members/requests', membership_source: @project, group: @group, requesters: @requesters
.card
.card-header
%strong= @project.name
= _('project members')
%span.badge.badge-pill= @project.users.size
- .float-right
- = link_to project_project_members_path(@project), class: 'btn btn-default gl-button btn-sm' do
- = sprite_icon('pencil-square', css_class: 'gl-icon')
- = _('Manage access')
+ = render 'shared/members/manage_access_button', path: project_project_members_path(@project)
%ul.content-list.project_members.members-list
= render partial: 'shared/members/member',
collection: @project_members, as: :member,
locals: { membership_source: @project,
group: @group,
- show_controls: false,
current_user_is_group_owner: current_user_is_group_owner }
.card-footer
= paginate @project_members, param_name: 'project_members_page', theme: 'gitlab'
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index da2fcd5a4a6..ce143a6b155 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -6,15 +6,15 @@
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
- if runner.instance_type?
- %span.badge.badge-pill.gl-badge.sm.badge-success= _("shared")
+ %span.badge.badge-pill.gl-badge.sm.badge-success= s_('Runners|shared')
- elsif runner.group_type?
- %span.badge.badge-pill.gl-badge.sm.badge-success= _("group")
+ %span.badge.badge-pill.gl-badge.sm.badge-success= s_('Runners|group')
- else
- %span.badge.badge-pill.gl-badge.sm.badge-info= _("specific")
+ %span.badge.badge-pill.gl-badge.sm.badge-info= s_('Runners|specific')
- if runner.locked?
- %span.badge.badge-pill.gl-badge.sm.badge-warning= _("locked")
+ %span.badge.badge-pill.gl-badge.sm.badge-warning= s_('Runners|locked')
- unless runner.active?
- %span.badge.badge-pill.gl-badge.sm.badge-danger= _("paused")
+ %span.badge.badge-pill.gl-badge.sm.badge-danger= s_('Runners|paused')
.table-section.section-30
.table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 07fbc3e5398..f9c52d9316b 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -17,23 +17,23 @@
%span= _('Runners can be:')
%ul
%li
- %span.badge.badge-pill.gl-badge.sm.badge-success shared
+ %span.badge.badge-pill.gl-badge.sm.badge-success= s_('Runners|shared')
\-
= _('Runs jobs from all unassigned projects.')
%li
- %span.badge.badge-pill.gl-badge.sm.badge-success group
+ %span.badge.badge-pill.gl-badge.sm.badge-success= s_('Runners|group')
\-
= _('Runs jobs from all unassigned projects in its group.')
%li
- %span.badge.badge-pill.gl-badge.sm.badge-info specific
+ %span.badge.badge-pill.gl-badge.sm.badge-info= s_('Runners|specific')
\-
= _('Runs jobs from assigned projects.')
%li
- %span.badge.badge-pill.gl-badge.sm.badge-warning locked
+ %span.badge.badge-pill.gl-badge.sm.badge-warning= s_('Runners|locked')
\-
= _('Cannot be assigned to other projects.')
%li
- %span.badge.badge-pill.gl-badge.sm.badge-danger paused
+ %span.badge.badge-pill.gl-badge.sm.badge-danger= s_('Runners|paused')
\-
= _('Not available to run jobs.')
@@ -41,7 +41,7 @@
.bs-callout
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
- type: 'shared',
+ type: s_('Runners|shared'),
reset_token_url: reset_registration_token_admin_application_settings_path,
project_path: '',
group_path: '' }
diff --git a/app/views/admin/users/_approve_user.html.haml b/app/views/admin/users/_approve_user.html.haml
deleted file mode 100644
index f61c9fa4b80..00000000000
--- a/app/views/admin/users/_approve_user.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.card.border-info
- .card-header.gl-bg-blue-500.gl-text-white
- = s_('AdminUsers|This user has requested access')
- .card-body
- = render partial: 'admin/users/user_approve_effects'
- %br
- = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?'), qa_selector: 'approve_user_button' }
diff --git a/app/views/admin/users/_ban_user.html.haml b/app/views/admin/users/_ban_user.html.haml
deleted file mode 100644
index 229c88adb7f..00000000000
--- a/app/views/admin/users/_ban_user.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- if ban_feature_available?
- .card.border-warning
- .card-header.bg-warning.gl-text-white
- = s_('AdminUsers|Ban user')
- .card-body
- = user_ban_effects
- %br
- %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_ban_data(user) }
- = s_('AdminUsers|Ban user')
diff --git a/app/views/admin/users/_block_user.html.haml b/app/views/admin/users/_block_user.html.haml
deleted file mode 100644
index 29029986345..00000000000
--- a/app/views/admin/users/_block_user.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-.card.border-warning
- .card-header.bg-warning.text-white
- = s_('AdminUsers|Block this user')
- .card-body
- = user_block_effects
- %br
- %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_block_data(user, s_('AdminUsers|You can always unblock their account, their data will remain intact.')) }
- = s_('AdminUsers|Block user')
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index be04e87f8b9..b7b712e078d 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -1,33 +1,38 @@
-%h3.page-title
- = @user.name
- - if @user.blocked_pending_approval?
- %span.cred
- = s_('AdminUsers|(Pending approval)')
- - elsif @user.banned?
- %span.cred
- = s_('AdminUsers|(Banned)')
- - elsif @user.blocked?
- %span.cred
- = s_('AdminUsers|(Blocked)')
- - if @user.internal?
- %span.cred
- = s_('AdminUsers|(Internal)')
- - if @user.admin
- %span.cred
- = s_('AdminUsers|(Admin)')
- - if @user.deactivated?
- %span.cred
- = s_('AdminUsers|(Deactivated)')
- = render_if_exists 'admin/users/auditor_user_badge'
- = render_if_exists 'admin/users/gma_user_badge'
+.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between.gl-align-items-center.gl-py-3.gl-mb-5.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
+ .gl-my-3
+ %h3.page-title.gl-m-0
+ = @user.name
+ - if @user.blocked_pending_approval?
+ %span.cred
+ = s_('AdminUsers|(Pending approval)')
+ - elsif @user.banned?
+ %span.cred
+ = s_('AdminUsers|(Banned)')
+ - elsif @user.blocked?
+ %span.cred
+ = s_('AdminUsers|(Blocked)')
+ - if @user.internal?
+ %span.cred
+ = s_('AdminUsers|(Internal)')
+ - if @user.admin
+ %span.cred
+ = s_('AdminUsers|(Admin)')
+ - if @user.deactivated?
+ %span.cred
+ = s_('AdminUsers|(Deactivated)')
+ = render_if_exists 'admin/users/auditor_user_badge'
+ = render_if_exists 'admin/users/gma_user_badge'
- .float-right
- - if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
- = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-info gl-button btn-grouped", data: { qa_selector: 'impersonate_user_link' }
- = link_to edit_admin_user_path(@user), class: "btn btn-default gl-button btn-grouped" do
- = sprite_icon('pencil-square', css_class: 'gl-icon gl-button-icon')
- = _('Edit')
-%hr
+ .gl-my-3.gl-display-flex.gl-flex-wrap.gl-my-n2.gl-mx-n2
+ .gl-p-2
+ #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) }
+ - if @user != current_user
+ .gl-p-2
+ - if impersonation_enabled? && @user.can?(:log_in)
+ = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-default gl-button", data: { qa_selector: 'impersonate_user_link' }
+ - if can_force_email_confirmation?(@user)
+ %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: confirm_user_data(@user) }
+ = _('Confirm user')
%ul.nav-links.nav.nav-tabs
= nav_link(path: 'users#show') do
= link_to _("Account"), admin_user_path(@user)
diff --git a/app/views/admin/users/_reject_pending_user.html.haml b/app/views/admin/users/_reject_pending_user.html.haml
deleted file mode 100644
index 17108427330..00000000000
--- a/app/views/admin/users/_reject_pending_user.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.card.border-danger
- .card-header.bg-danger.gl-text-white
- = s_('AdminUsers|This user has requested access')
- .card-body
- = render partial: 'admin/users/user_reject_effects'
- %br
- = link_to s_('AdminUsers|Reject request'), reject_admin_user_path(user), method: :delete, class: "btn gl-button btn-danger", data: { confirm: s_('AdminUsers|Are you sure?') }
diff --git a/app/views/admin/users/_user_activation_effects.html.haml b/app/views/admin/users/_user_activation_effects.html.haml
deleted file mode 100644
index 244836dac11..00000000000
--- a/app/views/admin/users/_user_activation_effects.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%p
- = s_('AdminUsers|Reactivating a user will:')
-%ul
- %li
- = s_('AdminUsers|Restore user access to the account, including web, Git and API.')
- = render_if_exists 'admin/users/user_activation_effects_on_seats'
diff --git a/app/views/admin/users/_user_approve_effects.html.haml b/app/views/admin/users/_user_approve_effects.html.haml
deleted file mode 100644
index 54e51bf3467..00000000000
--- a/app/views/admin/users/_user_approve_effects.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-%p
- = s_('AdminUsers|Approved users can:')
-%ul
- %li
- = s_('AdminUsers|Log in')
- %li
- = s_('AdminUsers|Access Git repositories')
- %li
- = s_('AdminUsers|Access the API')
- %li
- = s_('AdminUsers|Be added to groups and projects')
diff --git a/app/views/admin/users/_user_detail_note.html.haml b/app/views/admin/users/_user_detail_note.html.haml
index 4f2a682c5ca..cc4827327c9 100644
--- a/app/views/admin/users/_user_detail_note.html.haml
+++ b/app/views/admin/users/_user_detail_note.html.haml
@@ -1,7 +1,7 @@
- if @user.note.present?
- text = @user.note
- .card.border-info
- .card-header.bg-info.text-white
+ .card
+ .card-header
= _('Admin Note')
.card-body
%p= text
diff --git a/app/views/admin/users/_user_reject_effects.html.haml b/app/views/admin/users/_user_reject_effects.html.haml
deleted file mode 100644
index 17b6862b0cc..00000000000
--- a/app/views/admin/users/_user_reject_effects.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%p
- = s_('AdminUsers|Rejected users:')
-%ul
- %li
- = s_('AdminUsers|Cannot sign in or access instance information')
- %li
- = s_('AdminUsers|Will be deleted')
-%p
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") }
- = s_('AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml
index 5f9d11af7c1..28024ae084f 100644
--- a/app/views/admin/users/keys.html.haml
+++ b/app/views/admin/users/keys.html.haml
@@ -3,3 +3,4 @@
- page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml
index 3ff726e1945..8c56e888dcc 100644
--- a/app/views/admin/users/projects.html.haml
+++ b/app/views/admin/users/projects.html.haml
@@ -48,3 +48,5 @@
- if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
= sprite_icon('close', size: 16, css_class: 'gl-icon')
+
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 08c1e089f21..ad8d9d1f04f 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -16,8 +16,10 @@
%strong
= link_to user_path(@user) do
= @user.username
- = render 'admin/users/profile', user: @user
-
+ -# Rendered on mobile only so order of cards can be different on desktop vs mobile
+ .gl-md-display-none
+ = render 'admin/users/profile', user: @user
+ = render 'admin/users/user_detail_note'
.card
.card-header
= _('Account:')
@@ -139,123 +141,8 @@
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
- .col-md-6
- - unless @user == current_user
- - if can_force_email_confirmation?(@user)
- .gl-card.border-info.gl-mb-5
- .gl-card-header.bg-info.text-white
- = _('Confirm user')
- .gl-card-body
- - if @user.unconfirmed_email.present?
- - email = " (#{@user.unconfirmed_email})"
- %p= _('This user has an unconfirmed email address %{email}. You may force a confirmation.') % { email: email }
- %br
- = link_to _('Confirm user'), confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?'), qa_selector: 'confirm_user_button' }
-
- = render 'admin/users/user_detail_note'
-
- - unless @user.internal?
- - if @user.deactivated?
- .gl-card.border-info.gl-mb-5
- .gl-card-header.bg-info.text-white
- = _('Reactivate this user')
- .gl-card-body
- = render partial: 'admin/users/user_activation_effects'
- %br
- %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_activation_data(@user) }
- = s_('AdminUsers|Activate user')
- - elsif @user.can_be_deactivated?
- .gl-card.border-warning.gl-mb-5
- .gl-card-header.bg-warning.text-white
- = _('Deactivate this user')
- .gl-card-body
- = user_deactivation_effects
- %br
- %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_deactivation_data(@user, s_('AdminUsers|You can always re-activate their account, their data will remain intact.')) }
- = s_('AdminUsers|Deactivate user')
- - if @user.blocked?
- - if @user.blocked_pending_approval?
- = render 'admin/users/approve_user', user: @user
- = render 'admin/users/reject_pending_user', user: @user
- - elsif @user.banned?
- .gl-card.border-info.gl-mb-5
- .gl-card-header.gl-bg-blue-500.gl-text-white
- = _('This user is banned')
- .gl-card-body
- %p= _('A banned user cannot:')
- %ul
- %li= _('Log in')
- %li= _('Access Git repositories')
- - link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path("user/admin_area/moderate_users", anchor: "ban-a-user") }
- = s_('AdminUsers|Learn more about %{link_start}banned users.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- %p
- %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unban_data(@user) }
- = s_('AdminUsers|Unban user')
- - else
- .gl-card.border-info.gl-mb-5
- .gl-card-header.gl-bg-blue-500.gl-text-white
- = _('This user is blocked')
- .gl-card-body
- %p= _('A blocked user cannot:')
- %ul
- %li= _('Log in')
- %li= _('Access Git repositories')
- %br
- %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) }
- = s_('AdminUsers|Unblock user')
- - elsif !@user.internal?
- = render 'admin/users/block_user', user: @user
- = render 'admin/users/ban_user', user: @user
-
- - if @user.access_locked?
- .card.border-info.gl-mb-5
- .card-header.bg-info.text-white
- = _('This account has been locked')
- .card-body
- %p= _('This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.')
- %br
- = link_to _('Unlock user'), unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?') }
- - if !@user.blocked_pending_approval?
- .gl-card.border-danger.gl-mb-5
- .gl-card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user')
- .gl-card-body
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
- %p= _('Deleting a user has the following effects:')
- = render 'users/deletion_guidance', user: @user
- %br
- %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
- delete_user_url: admin_user_path(@user),
- block_user_url: block_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
- = s_('AdminUsers|Delete user')
- - else
- - if @user.solo_owned_groups.present?
- %p
- = _('This user is currently an owner in these groups:')
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
- %p
- = _('You must transfer ownership or delete these groups before you can delete this user.')
- - else
- %p
- = _("You don't have access to delete this user.")
-
- .gl-card.border-danger
- .gl-card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user and contributions')
- .gl-card-body
- - if can?(current_user, :destroy_user, @user)
- %p
- - link_to_ghost_user = link_to(_("system ghost user"), help_page_path("user/profile/account/delete_account"))
- = _("This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected.").html_safe % { link_to_ghost_user: link_to_ghost_user }
- %br
- %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
- delete_user_url: admin_user_path(@user, hard_delete: true),
- block_user_url: block_admin_user_path(@user),
- username: @user.name } }
- = s_('AdminUsers|Delete user and contributions')
- - else
- %p
- = _("You don't have access to delete this user.")
-
+ -# Rendered on desktop only so order of cards can be different on desktop vs mobile
+ .col-md-6.gl-display-none.gl-md-display-block
+ = render 'admin/users/profile', user: @user
+ = render 'admin/users/user_detail_note'
= render partial: 'admin/users/modals'
diff --git a/app/views/ci/token_access/_index.html.haml b/app/views/ci/token_access/_index.html.haml
new file mode 100644
index 00000000000..e6f21fc4ea4
--- /dev/null
+++ b/app/views/ci/token_access/_index.html.haml
@@ -0,0 +1 @@
+#js-ci-token-access-app{ data: { full_path: @project.full_path } }
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index 5eded970bf0..8a2a479486f 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,5 +1,5 @@
= _('Variables store information, like passwords and secret keys, that you can use in job scripts.')
-= link_to s_('Learn more.'), help_page_path('ci/variables/README'), target: '_blank', rel: 'noopener noreferrer'
+= link_to s_('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer'
%p
= _('Variables can be:')
%ul
@@ -7,4 +7,4 @@
= html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
%li
= html_escape(_('%{code_open}Masked:%{code_close} Hidden in job logs. Must match masking requirements.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to _('Learn more.'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index f5d28adfa66..9db5ee23c3e 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -2,7 +2,7 @@
- if ci_variable_protected_by_default?
%p.settings-message.text-center
- - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable') }
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- is_group = !@group.nil?
@@ -16,8 +16,8 @@
aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
- protected_environment_variables_link: help_page_path('ci/variables/README', anchor: 'protect-a-cicd-variable'),
- masked_environment_variables_link: help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'),
+ protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'),
+ masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'),
} }
- if !@group && @project.group
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 856d03ba258..3a7f7a241ac 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -39,7 +39,7 @@
= value
%p.masking-validation-error.gl-field-error.hide
= s_("CiVariables|Cannot use Masked Variable with current value")
- = link_to sprite_icon('question-o'), help_page_path('ci/variables/README', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to sprite_icon('question-o'), help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'), target: '_blank', rel: 'noopener noreferrer'
- unless only_key_value
.ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
.gl-mr-3
diff --git a/app/views/clusters/clusters/_applications.html.haml b/app/views/clusters/clusters/_applications.html.haml
deleted file mode 100644
index f83a414a0aa..00000000000
--- a/app/views/clusters/clusters/_applications.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-.cluster-applications-table#js-cluster-applications
diff --git a/app/views/clusters/clusters/_applications_tab.html.haml b/app/views/clusters/clusters/_applications_tab.html.haml
deleted file mode 100644
index e1455b0f60a..00000000000
--- a/app/views/clusters/clusters/_applications_tab.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- active = params[:tab] == 'apps'
-
-%li.nav-item{ role: 'presentation' }
- %a#cluster-apps-tab.nav-link.qa-applications{ class: active_when(active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'apps'}) }
- %span= _('Applications')
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 6d902132c73..1ca4f9c670e 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -6,17 +6,19 @@
%span.gl-spinner.gl-spinner-dark{ 'aria-label': 'Loading' }
%span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...')
-.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
- = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', css_class: 'gl-icon')
+= render 'shared/global_alert',
+ variant: :warning,
+ alert_class: 'hidden js-cluster-api-unreachable',
+ is_contained: true,
+ close_button_class: 'js-close' do
.gl-alert-body
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
-.hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
- = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', css_class: 'gl-icon')
+= render 'shared/global_alert',
+ variant: :warning,
+ alert_class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable',
+ is_contained: true,
+ close_button_class: 'js-close' do
.gl-alert-body
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
index da3e128ba32..f235435d907 100644
--- a/app/views/clusters/clusters/_multiple_clusters_message.html.haml
+++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml
@@ -1,4 +1,4 @@
-- autodevops_help_url = help_page_path('topics/autodevops/index.md', anchor: 'using-multiple-kubernetes-clusters')
+- autodevops_help_url = help_page_path('topics/autodevops/index.md', anchor: 'use-multiple-kubernetes-clusters')
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- help_link_end = '</a>'.html_safe
diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml
index a936cdc04dd..fe3d1998234 100644
--- a/app/views/clusters/clusters/_provider_details_form.html.haml
+++ b/app/views/clusters/clusters/_provider_details_form.html.haml
@@ -43,13 +43,13 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank'
.form-group
= field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank'
- if cluster.allow_user_defined_namespace?
= render('clusters/clusters/namespace', platform_field: platform_field, field: field)
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 93e8b1241a8..93db7db06b3 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -3,8 +3,8 @@
anchor: 'additional-requirements-for-self-managed-instances') }
= s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe }
- else
- .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
- 'namespace-per-environment-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'),
+ .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/gitlab_managed_clusters.md'),
+ 'namespace-per-environment-help-path' => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'),
'create-role-path' => clusterable.authorize_aws_role_path,
'create-cluster-path' => clusterable.create_aws_clusters_path,
'account-id' => Gitlab::CurrentSettings.eks_account_id,
@@ -12,6 +12,6 @@
'role-arn' => @aws_role.role_arn,
'instance-types' => @instance_types,
'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'),
- 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'new-eks-cluster'),
- 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'new-eks-cluster'),
+ 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'),
+ 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_eks_clusters.md', anchor: 'create-a-new-certificate-based-eks-cluster'),
'external-link-icon' => sprite_icon('external-link') } }
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 73a09f00fd6..5266fad9278 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -74,13 +74,13 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
.form-group
= field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank'
.form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 7336b9fe86b..0a482f1eb01 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -2,21 +2,10 @@
- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
- breadcrumb_title @cluster.name
- page_title _('Kubernetes Cluster')
-- manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project
- cluster_environments_path = clusterable.environments_cluster_path(@cluster)
- status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster)
.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
- install_helm_path: clusterable.install_applications_cluster_path(@cluster, :helm),
- install_ingress_path: clusterable.install_applications_cluster_path(@cluster, :ingress),
- install_cert_manager_path: clusterable.install_applications_cluster_path(@cluster, :cert_manager),
- install_crossplane_path: clusterable.install_applications_cluster_path(@cluster, :crossplane),
- install_prometheus_path: clusterable.install_applications_cluster_path(@cluster, :prometheus),
- install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner),
- install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter),
- install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative),
- update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative),
- install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack),
cluster_environments_path: cluster_environments_path,
toggle_status: @cluster.enabled? ? 'true': 'false',
has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false',
@@ -24,15 +13,11 @@
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
provider_type: @cluster.provider_type,
- pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false',
help_path: help_page_path('user/project/clusters/index.md'),
environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'),
- cloud_run_help_path: help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'),
- manage_prometheus_path: manage_prometheus_path,
- cluster_id: @cluster.id,
- cilium_help_path: help_page_path('user/clusters/applications.md', anchor: 'install-cilium-using-gitlab-cicd')} }
+ cluster_id: @cluster.id } }
.js-cluster-application-notice
.flash-container
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 7d82fe06799..e9b84952c15 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -47,13 +47,13 @@
label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank'
.form-group
= field.check_box :namespace_per_environment, { label: s_('ClusterIntegration|Namespace per environment'), label_class: 'label-bold' }
.form-text.text-muted
= s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'custom-namespace'), target: '_blank'
+ = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank'
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- if @user_cluster.allow_user_defined_namespace?
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index c24d386c412..4252b60514a 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -3,15 +3,6 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
-- if show_customize_homepage_banner?
- = content_for :customize_homepage_banner do
- .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" }
- .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'),
- preferences_behavior_path: profile_preferences_path(anchor: 'behavior'),
- callouts_path: user_callouts_path,
- callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE,
- track_label: 'home_page' } }
-
= render_dashboard_ultimate_trial(current_user)
- page_title _("Projects")
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 09b7f247450..a313ad7d23c 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -61,7 +61,7 @@
- if show_recaptcha_sign_up?
= recaptcha_tags nonce: content_security_policy_nonce
.submit-container
- = f.submit button_text, class: 'btn gl-button btn-confirm', data: { qa_selector: 'new_user_register_button' }
+ = f.submit button_text, class: 'btn gl-button btn-confirm gl-display-block gl-w-full', data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice', button_text: button_text
- if show_omniauth_providers && omniauth_providers_placement == :bottom
= render 'devise/shared/signup_omniauth_providers'
diff --git a/app/views/groups/_delete_project_button.html.haml b/app/views/groups/_delete_project_button.html.haml
new file mode 100644
index 00000000000..54a99319418
--- /dev/null
+++ b/app/views/groups/_delete_project_button.html.haml
@@ -0,0 +1 @@
+= link_to _('Delete'), project, data: { confirm: remove_project_message(project) }, method: :delete, class: "btn gl-button btn-danger"
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 624d0a21b81..b7c2b4d86b2 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -23,6 +23,10 @@
.home-panel-buttons.col-md-12.col-lg-6
- if current_user
.gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } }
+ - if current_user.admin?
+ = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon gl-mt-3 gl-mr-2', title: s_('View group in admin area'),
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('admin')
- if @notification_setting
.js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), group_id: @group.id, container_class: 'gl-mx-2 gl-mt-3 gl-vertical-align-top' } }
- if can_create_subgroups
diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index c70cc2c4936..3bc2146b313 100644
--- a/app/views/groups/_import_group_from_file_panel.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -33,8 +33,7 @@
title: _('Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
%p.validation-error.gl-field-error.field-validation.hide
- = _('Group path is already taken. Suggestions: ')
- %span.gl-path-suggestions
+ = _("Group path is already taken. We've suggested one that is available.")
%p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...')
.form-group
diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml
index f4f3c8ce8f7..3be1a142ca6 100644
--- a/app/views/groups/_invite_members_modal.html.haml
+++ b/app/views/groups/_invite_members_modal.html.haml
@@ -1,7 +1,8 @@
-- if can?(current_user, :admin_group_member, group)
- .js-invite-members-modal{ data: { id: group.id,
- name: group.name,
- is_project: 'false',
- access_levels: GroupMember.access_level_roles.to_json,
- default_access_level: Gitlab::Access::GUEST,
- help_link: help_page_url('user/permissions') }.merge(group_select_data(group)) }
+- return unless can_manage_members?(group)
+
+.js-invite-members-modal{ data: { id: group.id,
+ name: group.name,
+ is_project: 'false',
+ access_levels: GroupMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') }.merge(group_select_data(group)) }
diff --git a/app/views/groups/_project_badges.html.haml b/app/views/groups/_project_badges.html.haml
new file mode 100644
index 00000000000..1f7895e216c
--- /dev/null
+++ b/app/views/groups/_project_badges.html.haml
@@ -0,0 +1,2 @@
+- if project.archived
+ %span.badge.badge-warning.badge-pill.gl-badge.md= _('archived')
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index 33f836c2de0..ad916e3aeec 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -2,7 +2,7 @@
- page_title _("Merge requests")
-- if @merge_requests&.size == 0
+- if issuables_count_for_state(:merge_requests, :all) == 0
= render 'shared/empty_states/merge_requests', project_select_button: true
- else
.top-area
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 259e96901fd..a6302569187 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -1,25 +1,23 @@
= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
- .row
- .col-md-6
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :title, _("Title")
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
- .form-group.row.milestone-description
- .col-form-label.col-sm-2
- = f.label :description, _("Description")
- .col-sm-10
- = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
- .clearfix
- .error-alert
- = render "shared/milestones/form_dates", f: f
+ .form-group.row
+ .col-form-label.col-sm-2
+ = f.label :title, _("Title")
+ .col-sm-10
+ = f.text_field :title, maxlength: 255, class: "form-control", data: { qa_selector: "milestone_title_field" }, required: true, autofocus: true
+ = render "shared/milestones/form_dates", f: f
+ .form-group.row.milestone-description
+ .col-form-label.col-sm-2
+ = f.label :description, _("Description")
+ .col-sm-10
+ = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do
+ = render 'shared/zen', f: f, attr: :description,
+ classes: 'note-textarea',
+ qa_selector: 'milestone_description_field',
+ supports_autocomplete: true,
+ placeholder: _('Write milestone description...')
+ .clearfix
+ .error-alert
.form-actions
- if @milestone.new_record?
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 9d595d19779..9dbf60b119c 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -15,13 +15,12 @@
.controls
= link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button"
= link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button"
- = link_to _('Delete'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn gl-button btn-danger"
+ = render 'delete_project_button', project: project
.stats
%span.badge.badge-pill
= storage_counter(project.statistics&.storage_size)
- - if project.archived
- %span.badge.badge-warning archived
+ = render 'project_badges', project: project
.title
= link_to(project_path(project)) do
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index 823d908c5e2..49e297ee13d 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -9,19 +9,24 @@
-# Proper policies should be implemented per
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/45894
-- if can?(current_user, :admin_pipeline, @group)
- = render partial: 'ci/runner/how_to_setup_runner_automatically',
- locals: { type: 'group',
- clusters_path: group_clusters_path(@group) }
- - if params[:ci_runner_templates]
+.bs-callout.help-callout
+ - if can?(current_user, :admin_pipeline, @group) && valid_runner_registrars.include?('group')
+ = render partial: 'ci/runner/how_to_setup_runner_automatically',
+ locals: { type: 'group',
+ clusters_path: group_clusters_path(@group) }
+ - if params[:ci_runner_templates]
+ %hr
+ = render partial: 'ci/runner/setup_runner_in_aws',
+ locals: { registration_token: @group.runners_token }
%hr
- = render partial: 'ci/runner/setup_runner_in_aws',
- locals: { registration_token: @group.runners_token }
- %hr
- = render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @group.runners_token,
- type: 'group',
- reset_token_url: reset_registration_token_group_settings_ci_cd_path,
- project_path: '',
- group_path: @group.full_path }
- %br
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: @group.runners_token,
+ type: 'group',
+ reset_token_url: reset_registration_token_group_settings_ci_cd_path,
+ project_path: '',
+ group_path: @group.full_path }
+ %br
+ - else
+ = _('Please contact an admin to register runners.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'runner-registration'), target: '_blank', rel: 'noopener noreferrer'
+
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 89e32c0999c..13da2292985 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -7,16 +7,16 @@
.table-mobile-content
- if runner.group_type?
%span.badge.badge-pill.gl-badge.sm.badge-success
- = _('group')
+ = s_('Runners|group')
- else
%span.badge.badge-pill.gl-badge.sm.badge-info
- = _('specific')
+ = s_('Runners|specific')
- if runner.locked?
%span.badge.badge-pill.gl-badge.sm.badge-warning
- = _('locked')
+ = s_('Runners|locked')
- unless runner.active?
%span.badge.badge-pill.gl-badge.sm.badge-danger
- = _('paused')
+ = s_('Runners|paused')
.table-section.section-30
.table-mobile-header{ role: 'rowheader' }= s_('Runners|Runner')
diff --git a/app/views/groups/runners/_index.html.haml b/app/views/groups/runners/_settings.html.haml
index 187588f5f11..187588f5f11 100644
--- a/app/views/groups/runners/_index.html.haml
+++ b/app/views/groups/runners/_settings.html.haml
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index d7a145924de..fea0736ffc8 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -24,21 +24,6 @@
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
= f.submit s_('GroupSettings|Change group URL'), class: 'btn gl-button btn-warning'
-.sub-section
- %h4.warning-title= s_('GroupSettings|Transfer group')
- = form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
- .form-group
- = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group), qa_selector: 'select_group_dropdown' } })
- = hidden_field_tag 'new_parent_group_id'
-
- %ul
- - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'.html_safe
- - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end: '</a>'.html_safe }
- %li= warning_text.html_safe
- %li= s_('GroupSettings|You can only transfer the group to a group you manage.')
- %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
- %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
-
+= render 'groups/settings/transfer', group: @group
= render 'groups/settings/remove', group: @group
= render_if_exists 'groups/settings/restore', group: @group
diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml
index a7b1813e4f1..456e0b0f1d0 100644
--- a/app/views/groups/settings/_pages_settings.html.haml
+++ b/app/views/groups/settings/_pages_settings.html.haml
@@ -2,4 +2,4 @@
= render_if_exists 'shared/pages/max_pages_size_input', form: f
.gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn gl-button btn-confirm'
+ = f.submit s_('GitLabPages|Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml
new file mode 100644
index 00000000000..1472ae42152
--- /dev/null
+++ b/app/views/groups/settings/_transfer.html.haml
@@ -0,0 +1,22 @@
+.sub-section
+ %h4.warning-title= s_('GroupSettings|Transfer group')
+ = form_for group, url: transfer_group_path(group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
+ .form-group
+ = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', disabled: group.paid?, data: { data: parent_group_options(group), qa_selector: 'select_group_dropdown' } })
+ = hidden_field_tag 'new_parent_group_id'
+
+ %ul
+ - side_effects_link_start = '<a href="https://docs.gitlab.com/ee/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'.html_safe
+ - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end: '</a>'.html_safe }
+ %li= warning_text.html_safe
+ %li= s_('GroupSettings|You can only transfer the group to a group you manage.')
+ %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
+ %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
+
+ - if group.paid?
+ .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-to-transfer-has-linked-subscription-alert' } }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ = html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }
+
+ = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 3c6514b95b8..018dd4c424d 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -32,9 +32,9 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
- = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = render 'groups/runners/index'
+ = render 'groups/runners/settings'
%section.settings#auto-devops-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/groups/settings/integrations/index.html.haml b/app/views/groups/settings/integrations/index.html.haml
index 7a81d53c085..1db8edb040b 100644
--- a/app/views/groups/settings/integrations/index.html.haml
+++ b/app/views/groups/settings/integrations/index.html.haml
@@ -1,9 +1,9 @@
-- breadcrumb_title _('Integrations')
-- page_title _('Integrations')
+- breadcrumb_title s_('Integrations|Group-level integration management')
+- page_title s_('Integrations|Group-level integration management')
- @content_class = 'limit-container-width' unless fluid_layout
-%h3= s_('Integrations|Project integration management')
+%h3= s_('Integrations|Group-level integration management')
- integrations_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: integrations_help_page_path }
-%p= s_("Integrations|GitLab administrators can set up integrations that all projects inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}project integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
+%p= s_("Integrations|GitLab administrators can set up integrations that all projects in a group inherit and use by default. These integrations apply to all projects that don't already use custom settings. You can override custom settings for a project if the settings are necessary at that level. Learn more about %{integrations_link_start}group-level integration management%{link_end}.").html_safe % { integrations_link_start: integrations_link_start, link_end: "</a>".html_safe }
= render 'shared/integrations/index', integrations: @integrations
diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_initial_branch_name.html.haml
index 23ac7d51e4f..5299c38576d 100644
--- a/app/views/groups/settings/repository/_initial_branch_name.html.haml
+++ b/app/views/groups/settings/repository/_initial_branch_name.html.haml
@@ -5,7 +5,7 @@
%button.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Set the default name of the initial branch when creating new repositories through the user interface.')
+ = s_('GroupSettings|The default name for the initial branch of new repositories created in the group.')
.settings-content
= form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
= form_errors(@group)
@@ -16,7 +16,7 @@
= f.label :default_branch_name, _('Default initial branch name'), class: 'label-light'
= f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: Gitlab::DefaultBranch.value(object: @group), class: 'form-control'
%span.form-text.text-muted
- = (_("Changes affect new repositories only. If not specified, either the configured application-wide default or Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name }).html_safe
+ = (s_("GroupSettings|If not specified at the group or instance level, the default is %{default_initial_branch_name}. Does not affect existing repositories.") % { default_initial_branch_name: fallback_branch_name }).html_safe
= f.hidden_field :redirect_target, value: "repository_settings"
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm'
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 628425bf463..76850f0a884 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -6,6 +6,8 @@
- if show_thanks_for_purchase_banner?
= render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
+= render_if_exists 'shared/qrtly_reconciliation_alert', group: @group
+
- if show_invite_banner?(@group)
= content_for :group_invite_members_banner do
.container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" }
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index a56eaaf685f..95888963947 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -38,7 +38,7 @@
.card-header
= _('Quick help')
%ul.content-list
- %li= link_to _('See our website for getting help'), support_url
+ %li= link_to _('See our website for help'), support_url
%li
%button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' }
= _('Use the search bar on the top of this page')
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 8daddbb0042..028268482cd 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -21,5 +21,5 @@
= file_field_tag :file, class: ''
.row
.form-actions.col-sm-12
- = submit_tag _('Import project'), class: 'gl-button btn btn-confirm'
+ = submit_tag _('Import project'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'import_project_button' }
= link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
index 7de8b0ee10f..16526382f42 100644
--- a/app/views/import/shared/_new_project_form.html.haml
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -1,7 +1,7 @@
.row
.form-group.project-name.col-sm-12
= label_tag :name, _('Project name'), class: 'label-bold'
- = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true }
+ = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true }, data: { qa_selector: 'project_name_field' }
.form-group.col-12.col-sm-6
= label_tag :namespace_id, _('Project URL'), class: 'label-bold'
.form-group
@@ -18,4 +18,4 @@
= hidden_field_tag :namespace_id, current_user.namespace_id
.form-group.col-12.col-sm-6.project-path
= label_tag :path, _('Project slug'), class: 'label-bold'
- = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true }
+ = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_slug_field' }
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index 433337602f1..a302fa605e7 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -4,6 +4,8 @@
- flash.each do |key, value|
- if key == 'toast' && value
.js-toast-message{ data: { message: value } }
+ - elsif value == I18n.t('devise.failure.unconfirmed')
+ = render 'shared/confirm_your_email_alert'
- elsif value
%div{ class: "flash-#{key} mb-2" }
= sprite_icon(icons[key], css_class: 'align-middle mr-1') unless icons[key].nil?
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index cd1a236b6be..c431f05c217 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -7,5 +7,5 @@
- else
%link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
%link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
- - if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
+ - if Gitlab::Tracking.enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
%link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 2b63e2c647c..6c959f5e60c 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -14,11 +14,12 @@
= render "layouts/header/service_templates_deprecation_callout"
= render "layouts/nav/classification_level_banner"
= yield :flash_message
- = render "shared/ping_consent"
+ = render "shared/service_ping_consent"
= render_account_recovery_regular_check
= render_if_exists "layouts/header/ee_subscribable_banner"
= render_if_exists "shared/namespace_storage_limit_alert"
= render_if_exists "shared/new_user_signups_cap_reached_alert"
+ = yield :page_level_alert
= yield :customize_homepage_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
@@ -27,5 +28,6 @@
= render "layouts/flash", extra_flash_class: 'limit-container-width'
= yield :before_content
= yield
+ = yield :after_content
= render "layouts/nav/top_nav_responsive", class: 'layout-page content-wrapper-margin'
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index e617b4358e3..5ce275d4a43 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -3,14 +3,14 @@
.search-input-container
.search-input-wrap
.dropdown{ data: { url: search_autocomplete_path } }
- = search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
+ = search_field_tag 'search', nil, placeholder: _('Search GitLab'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
autocomplete: 'off',
data: { issues_path: issues_dashboard_path,
mr_path: merge_requests_dashboard_path,
qa_selector: 'search_term_field' },
- aria: { label: _('Search or jump to…') }
+ aria: { label: _('Search GitLab') }
%button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
.dropdown-menu.dropdown-select{ data: { testid: 'dashboard-search-options' } }
= dropdown_content do
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index bdce4eac755..9c0384e5faa 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -1,4 +1,4 @@
-- return unless Gitlab::CurrentSettings.snowplow_enabled?
+- return unless Gitlab::Tracking.enabled?
= javascript_tag do
:plain
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 47c092e199a..899bf65de48 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,7 +1,6 @@
- page_classes = page_class << @html_class
- page_classes = page_classes.flatten.compact
- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list]
-- body_classes << 'sidebar-refactoring' if sidebar_refactor_enabled?
!!! 5
%html{ lang: I18n.locale, class: page_classes }
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index ae7c160c060..5c9c6a06ac1 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -14,28 +14,29 @@
.row.mt-3
.col-sm-12
%h1.mb-3.font-weight-normal
- = current_appearance&.title.presence || "GitLab"
+ = current_appearance&.title.presence || _('GitLab')
.row.mb-3
.col-sm-7.order-12.order-sm-1.brand-holder
- = brand_image
- - if current_appearance&.description?
- = brand_text
- - else
- %h3.mt-sm-0
- = _('A complete DevOps platform')
+ - unless recently_confirmed_com?
+ = brand_image
+ - if current_appearance&.description?
+ = brand_text
+ - else
+ %h3.gl-sm-mt-0
+ = _('A complete DevOps platform')
- %p
- = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.')
+ %p
+ = _('GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.')
- %p
- = _('This is a self-managed instance of GitLab.')
+ %p
+ = _('This is a self-managed instance of GitLab.')
- if Gitlab::CurrentSettings.sign_in_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
= render_if_exists 'layouts/devise_help_text'
- .col-sm-5.order-1.order-sm-12.new-session-forms-container
+ .col-sm-5.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
= yield
= render 'devise/shared/footer', footer_message: footer_message
diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml
index c5f43fb2c16..0be87ad963c 100644
--- a/app/views/layouts/header/_new_dropdown.html.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -6,7 +6,7 @@
- return if menu_sections.empty?
-%li.header-new.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }
+%li.header-new.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_event: "click_dropdown" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", id: "js-onboarding-new-project-link", title: title, ref: 'tooltip', aria: { label: title }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', qa_selector: 'new_menu_toggle' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index 9266702e44e..25a7f7ba9d7 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -7,8 +7,8 @@
alert_data: { feature_id: UserCalloutsHelper::REGISTRATION_ENABLED_CALLOUT, dismiss_endpoint: user_callouts_path },
close_button_data: { testid: 'close-registration-enabled-callout' } do
.gl-alert-body
- = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\">".html_safe, anchorClose: '</a>'.html_safe }
+ = html_escape(_('%{anchorOpen}Learn more%{anchorClose} about how you can customize / disable registration on your instance.')) % { anchorOpen: "<a href=\"#{help_page_path('user/admin_area/settings/sign_up_restrictions')}\" class=\"gl-link\">".html_safe, anchorClose: '</a>'.html_safe }
.gl-alert-actions
- = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-info btn-md gl-button' do
+ = link_to general_admin_application_settings_path(anchor: 'js-signup-settings'), class: 'btn gl-alert-action btn-confirm btn-md gl-button' do
%span.gl-button-text
= _('View setting')
diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml
new file mode 100644
index 00000000000..b5cb8f2af37
--- /dev/null
+++ b/app/views/layouts/minimal.html.haml
@@ -0,0 +1,18 @@
+- page_classes = page_class.push(@html_class).flatten.compact
+
+!!! 5
+%html{ lang: I18n.locale, class: page_classes }
+ = render "layouts/head"
+ %body{ data: body_data }
+ = header_message
+ = render 'peek/bar'
+ = render "layouts/header/empty"
+ .layout-page
+ .content-wrapper.content-wrapper-margin.gl-pt-6{ class: 'gl-md-pt-11!' }
+ .alert-wrapper.gl-force-block-formatting-context
+ = render "layouts/broadcast"
+ .limit-container-width{ class: container_class }
+ %main#content-body.content
+ = render "layouts/flash" unless @hide_flash
+ = yield
+ = footer_message
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 117382d87b5..e4cdb4e1b08 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -4,7 +4,7 @@
-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }) do
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do
%button{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('chevron-down', css_class: 'caret-down')
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index 46070975566..f16aab92a95 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -15,21 +15,12 @@
= nav_link(path: 'projects#trending') do
= link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do
= _('Explore projects')
- - experiment(:new_repo, user: current_user) do |e|
- - e.use do
- = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
- = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "create_project_link" } do
- = _('Create blank project')
- = nav_link(path: 'projects/new#import_project') do
- = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo", qa_selector: "import_project_link" } do
- = _('Import project')
- - e.try do
- = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
- = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", track_experiment: "new_repo" } do
- = _('Create blank project/repository')
- = nav_link(path: 'projects/new#import_project') do
- = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", track_experiment: "new_repo" } do
- = _('Import project/repository')
+ = nav_link(path: 'projects/new#blank_project', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do
+ = link_to new_project_path(anchor: 'blank_project'), data: { track_label: "projects_dropdown_blank_project", track_event: "click_link", qa_selector: "create_project_link" } do
+ = _('Create blank project')
+ = nav_link(path: 'projects/new#import_project') do
+ = link_to new_project_path(anchor: 'import_project'), data: { track_label: "projects_dropdown_import_project", track_event: "click_link", qa_selector: "import_project_link" } do
+ = _('Import project')
= nav_link(path: 'projects/new#create_from_template') do
= link_to new_project_path(anchor: 'create_from_template'), data: { track_label: "projects_dropdown_create_from_template", track_event: "click_link" } do
= _('Create from template')
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 21c3d7cb7e2..d0b73a3364a 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -1,12 +1,9 @@
-- avatar_size = sidebar_refactor_disabled? ? 24 : 18
-- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32'
-
%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') }
.nav-sidebar-inner-scroll
.context-header
= link_to admin_root_path, title: _('Admin Overview') do
- %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', avatar_size_class] }
- = sprite_icon('admin', size: avatar_size)
+ %span{ class: ['avatar-container', 'settings-avatar', 'rect-avatar', 's32'] }
+ = sprite_icon('admin', size: 18)
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
@@ -209,19 +206,6 @@
= render_if_exists 'layouts/nav/sidebar/credentials_link'
- - if show_service_templates_nav_link?
- = nav_link(controller: :services) do
- = link_to admin_application_settings_services_path do
- .nav-icon-container
- = sprite_icon('template')
- %span.nav-item-name
- = _('Service Templates')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :services, html_options: { class: "fly-out-top-item" } ) do
- = link_to admin_application_settings_services_path do
- %strong.fly-out-top-item-name
- = _('Service Templates')
-
= nav_link(controller: :labels) do
= link_to admin_labels_path do
.nav-icon-container
diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
index 58989d6afc4..92a7b97203f 100644
--- a/app/views/layouts/nav/sidebar/_analytics_links.html.haml
+++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
@@ -1,6 +1,6 @@
- navbar_links = links.sort_by(&:title)
- all_paths = navbar_links.map(&:path)
-- analytics_link = navbar_links.find { |link| link.title == _('Value Stream') } || navbar_links.first
+- analytics_link = navbar_links.find { |link| link.title == _('Value stream') } || navbar_links.first
- if navbar_links.any?
= nav_link(path: all_paths) do
diff --git a/app/views/layouts/nav/sidebar/_context_menu_body.html.haml b/app/views/layouts/nav/sidebar/_context_menu_body.html.haml
deleted file mode 100644
index 321bcda5702..00000000000
--- a/app/views/layouts/nav/sidebar/_context_menu_body.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32'
-- avatar_classes = ['avatar-container', 'rect-avatar', 'group-avatar']
-- avatar_classes << avatar_size_class
-
-= link_to group_path(@group), title: @group.name, data: { qa_selector: 'group_scope_link' } do
- %span{ class: avatar_classes }
- = group_icon(@group, class: ['avatar', 'avatar-tile', avatar_size_class])
- %span.sidebar-context-title
- = @group.name
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 0ce1d48a2de..980730bc3be 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,207 +1,3 @@
-- issues_count = cached_issuables_count(@group, type: :issues)
-- merge_requests_count = cached_issuables_count(@group, type: :merge_requests)
-- aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation')
-
-%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title }
- .nav-sidebar-inner-scroll
- - if sidebar_refactor_disabled?
- .context-header
- = render 'layouts/nav/sidebar/context_menu_body'
-
- %ul.sidebar-top-level-items.qa-group-sidebar
- - if sidebar_refactor_enabled?
- = nav_link(path: ['groups#show', 'groups#details'], html_options: { class: 'context-header' }) do
- = render 'layouts/nav/sidebar/context_menu_body'
-
- = render_if_exists 'layouts/nav/sidebar/group_trial_status_widget', group: @group
-
- - if group_sidebar_link?(:overview)
- - paths = group_overview_nav_link_paths
- = nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do
- - information_link = sidebar_refactor_enabled? ? activity_group_path(@group) : group_path(@group)
- = link_to information_link, class: 'has-sub-items', data: { qa_selector: 'group_information_link' } do
- .nav-icon-container
- - sprite = sidebar_refactor_enabled? ? 'group' : 'home'
- = sprite_icon(sprite)
- %span.nav-item-name
- = group_information_title(@group)
-
- %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_information_submenu'} }
- = nav_link(path: paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to information_link do
- %strong.fly-out-top-item-name
- = group_information_title(@group)
- %li.divider.fly-out-top-item
-
- - if sidebar_refactor_disabled?
- = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to details_group_path(@group), title: _('Group details') do
- %span
- = _('Details')
-
- - if group_sidebar_link?(:activity)
- = nav_link(path: 'groups#activity') do
- = link_to activity_group_path(@group), title: _('Activity') do
- %span
- = _('Activity')
-
- - if group_sidebar_link?(:labels) && sidebar_refactor_enabled?
- = nav_link(path: 'labels#index') do
- = link_to group_labels_path(@group), title: _('Labels') do
- %span
- = _('Labels')
-
- - if sidebar_refactor_enabled?
- - if group_sidebar_link?(:group_members)
- = nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group), title: _('Members'), data: { qa_selector: 'group_members_item' } do
- %span
- = _('Members')
-
- = render_if_exists "layouts/nav/ee/epic_link", group: @group
-
- - if group_sidebar_link?(:issues)
- = nav_link(path: group_issues_sub_menu_items, unless: -> { current_path?('issues_analytics#show') }) do
- = link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' }, class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('issues')
- %span.nav-item-name
- = _('Issues')
- %span.badge.badge-pill.count= issues_count
-
- %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} }
- = nav_link(path: group_issues_sub_menu_items, html_options: { class: "fly-out-top-item" } ) do
- = link_to issues_group_path(@group) do
- %strong.fly-out-top-item-name
- = _('Issues')
- %span.badge.badge-pill.count.issue_counter.fly-out-badge= issues_count
-
- %li.divider.fly-out-top-item
- = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
- = link_to issues_group_path(@group), title: _('List') do
- %span
- = _('List')
-
- - if group_sidebar_link?(:boards)
- = nav_link(path: ['boards#index', 'boards#show']) do
- = link_to group_boards_path(@group), title: boards_link_text, data: { qa_selector: 'group_issue_boards_link' } do
- %span
- = boards_link_text
-
- - if group_sidebar_link?(:labels) && sidebar_refactor_disabled?
- = nav_link(path: 'labels#index') do
- = link_to group_labels_path(@group), title: _('Labels') do
- %span
- = _('Labels')
-
- - if group_sidebar_link?(:milestones)
- = nav_link(path: 'milestones#index') do
- = link_to group_milestones_path(@group), title: _('Milestones'), data: { qa_selector: 'group_milestones_link' } do
- %span
- = _('Milestones')
-
- = render_if_exists 'layouts/nav/sidebar/group_iterations_link'
-
- - if group_sidebar_link?(:merge_requests)
- = nav_link(path: 'groups#merge_requests') do
- = link_to merge_requests_group_path(@group) do
- .nav-icon-container
- = sprite_icon('git-merge')
- %span.nav-item-name
- = _('Merge requests')
- %span.badge.badge-pill.count= merge_requests_count
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
- = link_to merge_requests_group_path(@group) do
- %strong.fly-out-top-item-name
- = _('Merge requests')
- %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= merge_requests_count
-
- = render_if_exists "layouts/nav/ee/security_link" # EE-specific
-
- = render_if_exists "layouts/nav/ee/push_rules_link" # EE-specific
-
- - if group_sidebar_link?(:kubernetes)
- = nav_link(controller: [:clusters]) do
- = link_to group_clusters_path(@group) do
- .nav-icon-container
- = sprite_icon('cloud-gear')
- %span.nav-item-name
- = _('Kubernetes')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: [:clusters], html_options: { class: "fly-out-top-item" } ) do
- = link_to group_clusters_path(@group), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
- %strong.fly-out-top-item-name
- = _('Kubernetes')
-
- = render 'groups/sidebar/packages'
-
- = render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user)
-
- - if group_sidebar_link?(:wiki)
- = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url
-
- - if sidebar_refactor_disabled?
- - if group_sidebar_link?(:group_members)
- = nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group) do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name.qa-group-members-item
- = _('Members')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
- = link_to group_group_members_path(@group) do
- %strong.fly-out-top-item-name
- = _('Members')
-
- - if group_sidebar_link?(:settings)
- = nav_link(path: group_settings_nav_link_paths) do
- = link_to edit_group_path(@group), class: 'has-sub-items' do
- .nav-icon-container
- = sprite_icon('settings')
- %span.nav-item-name{ data: { qa_selector: 'group_settings' } }
- = _('Settings')
- %ul.sidebar-sub-level-items.qa-group-sidebar-submenu{ data: { testid: 'group-settings-menu' } }
- = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show groups/applications#index], html_options: { class: "fly-out-top-item" } ) do
- = link_to edit_group_path(@group) do
- %strong.fly-out-top-item-name
- = _('Settings')
- %li.divider.fly-out-top-item
- = nav_link(path: 'groups#edit') do
- = link_to edit_group_path(@group), title: _('General'), data: { qa_selector: 'general_settings_link' } do
- %span
- = _('General')
-
- = nav_link(controller: :integrations) do
- = link_to group_settings_integrations_path(@group), title: _('Integrations') do
- %span
- = _('Integrations')
-
- = nav_link(path: 'groups#projects') do
- = link_to projects_group_path(@group), title: _('Projects') do
- %span
- = _('Projects')
-
- = nav_link(controller: :repository) do
- = link_to group_settings_repository_path(@group), title: _('Repository') do
- %span
- = _('Repository')
-
- = nav_link(controller: [:ci_cd, 'groups/runners']) do
- = link_to group_settings_ci_cd_path(@group), title: _('CI/CD') do
- %span
- = _('CI/CD')
-
- = nav_link(controller: :applications) do
- = link_to group_settings_applications_path(@group), title: _('Applications') do
- %span
- = _('Applications')
-
- = render 'groups/sidebar/packages_settings'
-
- = render_if_exists "groups/ee/settings_nav"
-
- = render_if_exists "groups/ee/administration_nav"
-
- = render 'shared/sidebar_toggle_button'
+-# We're migration the group sidebar to a logical model based structure. If you need to update
+-# any of the existing menus, you can find them in app/views/layouts/nav/sidebar/_group_menus.html.haml.
+= render partial: 'shared/nav/sidebar', object: Sidebars::Groups::Panel.new(group_sidebar_context(@group, current_user))
diff --git a/app/views/layouts/nav/sidebar/_group_menus.html.haml b/app/views/layouts/nav/sidebar/_group_menus.html.haml
new file mode 100644
index 00000000000..5738c8becd5
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_group_menus.html.haml
@@ -0,0 +1,166 @@
+- issues_count = cached_issuables_count(@group, type: :issues)
+- merge_requests_count = cached_issuables_count(@group, type: :merge_requests)
+
+= render_if_exists 'layouts/nav/sidebar/group_trial_status_widget', group: @group
+
+- if group_sidebar_link?(:overview)
+ - paths = group_overview_nav_link_paths
+ = nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do
+ = link_to activity_group_path(@group), class: 'has-sub-items', data: { qa_selector: 'group_information_link' } do
+ .nav-icon-container
+ = sprite_icon('group')
+ %span.nav-item-name
+ = group_information_title(@group)
+
+ %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_information_submenu'} }
+ = nav_link(path: paths, html_options: { class: "fly-out-top-item" } ) do
+ = link_to activity_group_path(@group) do
+ %strong.fly-out-top-item-name
+ = group_information_title(@group)
+ %li.divider.fly-out-top-item
+
+ - if group_sidebar_link?(:activity)
+ = nav_link(path: 'groups#activity') do
+ = link_to activity_group_path(@group), title: _('Activity') do
+ %span
+ = _('Activity')
+
+ - if group_sidebar_link?(:labels)
+ = nav_link(path: 'labels#index') do
+ = link_to group_labels_path(@group), title: _('Labels') do
+ %span
+ = _('Labels')
+
+ - if group_sidebar_link?(:group_members)
+ = nav_link(path: 'group_members#index') do
+ = link_to group_group_members_path(@group), title: _('Members'), data: { qa_selector: 'group_members_item' } do
+ %span
+ = _('Members')
+
+= render_if_exists "layouts/nav/ee/epic_link", group: @group
+
+- if group_sidebar_link?(:issues)
+ = nav_link(path: group_issues_sub_menu_items, unless: -> { current_path?('issues_analytics#show') }) do
+ = link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' }, class: 'has-sub-items' do
+ .nav-icon-container
+ = sprite_icon('issues')
+ %span.nav-item-name
+ = _('Issues')
+ %span.badge.badge-pill.count= issues_count
+
+ %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} }
+ = nav_link(path: group_issues_sub_menu_items, html_options: { class: "fly-out-top-item" } ) do
+ = link_to issues_group_path(@group) do
+ %strong.fly-out-top-item-name
+ = _('Issues')
+ %span.badge.badge-pill.count.issue_counter.fly-out-badge= issues_count
+
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
+ = link_to issues_group_path(@group), title: _('List') do
+ %span
+ = _('List')
+
+ - if group_sidebar_link?(:boards)
+ = nav_link(path: ['boards#index', 'boards#show']) do
+ = link_to group_boards_path(@group), title: boards_link_text, data: { qa_selector: 'group_issue_boards_link' } do
+ %span
+ = boards_link_text
+
+ - if group_sidebar_link?(:milestones)
+ = nav_link(path: 'milestones#index') do
+ = link_to group_milestones_path(@group), title: _('Milestones'), data: { qa_selector: 'group_milestones_link' } do
+ %span
+ = _('Milestones')
+
+ = render_if_exists 'layouts/nav/sidebar/group_iterations_link'
+
+- if group_sidebar_link?(:merge_requests)
+ = nav_link(path: 'groups#merge_requests') do
+ = link_to merge_requests_group_path(@group) do
+ .nav-icon-container
+ = sprite_icon('git-merge')
+ %span.nav-item-name
+ = _('Merge requests')
+ %span.badge.badge-pill.count= merge_requests_count
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'groups#merge_requests', html_options: { class: "fly-out-top-item" } ) do
+ = link_to merge_requests_group_path(@group) do
+ %strong.fly-out-top-item-name
+ = _('Merge requests')
+ %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= merge_requests_count
+
+= render_if_exists "layouts/nav/ee/security_link" # EE-specific
+
+= render_if_exists "layouts/nav/ee/push_rules_link" # EE-specific
+
+- if group_sidebar_link?(:kubernetes)
+ = nav_link(controller: [:clusters]) do
+ = link_to group_clusters_path(@group) do
+ .nav-icon-container
+ = sprite_icon('cloud-gear')
+ %span.nav-item-name
+ = _('Kubernetes')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: [:clusters], html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_clusters_path(@group), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do
+ %strong.fly-out-top-item-name
+ = _('Kubernetes')
+
+= render 'groups/sidebar/packages'
+
+= render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user)
+
+- if group_sidebar_link?(:wiki)
+ = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url
+
+- if group_sidebar_link?(:settings)
+ = nav_link(path: group_settings_nav_link_paths) do
+ = link_to edit_group_path(@group), class: 'has-sub-items' do
+ .nav-icon-container
+ = sprite_icon('settings')
+ %span.nav-item-name{ data: { qa_selector: 'group_settings' } }
+ = _('Settings')
+ %ul.sidebar-sub-level-items{ data: { testid: 'group-settings-menu', qa_selector: 'group_sidebar_submenu' } }
+ = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show groups/applications#index], html_options: { class: "fly-out-top-item" } ) do
+ = link_to edit_group_path(@group) do
+ %strong.fly-out-top-item-name
+ = _('Settings')
+ %li.divider.fly-out-top-item
+ = nav_link(path: 'groups#edit') do
+ = link_to edit_group_path(@group), title: _('General'), data: { qa_selector: 'general_settings_link' } do
+ %span
+ = _('General')
+
+ = nav_link(controller: :integrations) do
+ = link_to group_settings_integrations_path(@group), title: _('Integrations') do
+ %span
+ = _('Integrations')
+
+ = nav_link(path: 'groups#projects') do
+ = link_to projects_group_path(@group), title: _('Projects') do
+ %span
+ = _('Projects')
+
+ = nav_link(controller: :repository) do
+ = link_to group_settings_repository_path(@group), title: _('Repository') do
+ %span
+ = _('Repository')
+
+ = nav_link(controller: [:ci_cd, 'groups/runners']) do
+ = link_to group_settings_ci_cd_path(@group), title: _('CI/CD') do
+ %span
+ = _('CI/CD')
+
+ = nav_link(controller: :applications) do
+ = link_to group_settings_applications_path(@group), title: _('Applications') do
+ %span
+ = _('Applications')
+
+ = render 'groups/sidebar/packages_settings'
+
+ = render_if_exists "groups/ee/settings_nav"
+
+= render_if_exists "groups/ee/administration_nav"
+
+= render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index daafabdb799..4db1e532ba5 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -1,12 +1,9 @@
-- avatar_size = sidebar_refactor_disabled? ? 40 : 32
-- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32'
-
%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(current_user), 'aria-label': _('User settings') }
.nav-sidebar-inner-scroll
.context-header
= link_to profile_path, title: _('Profile Settings') do
- %span{ class: ['avatar-container', 'settings-avatar', avatar_size_class] }
- = image_tag avatar_icon_for_user(current_user, avatar_size), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', avatar_size_class], alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
+ %span{ class: ['avatar-container', 'settings-avatar', 's32'] }
+ = image_tag avatar_icon_for_user(current_user, 32), class: ['avatar', 'avatar-tile', 'js-sidebar-user-avatar', 's32'], alt: current_user.name, data: { testid: 'sidebar-user-avatar' }
%span.sidebar-context-title= _('User Settings')
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
diff --git a/app/views/layouts/welcome.html.haml b/app/views/layouts/welcome.html.haml
deleted file mode 100644
index 944f524d692..00000000000
--- a/app/views/layouts/welcome.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-!!! 5
-%html.subscriptions-layout-html{ lang: 'en' }
- = render 'layouts/head'
- %body.ui-indigo.gl-display-flex.vh-100
- = render "layouts/header/logo_with_title"
- = render "layouts/broadcast"
- .container.gl-display-flex.gl-flex-grow-1
- = yield
diff --git a/app/views/notify/access_token_about_to_expire_email.html.haml b/app/views/notify/access_token_about_to_expire_email.html.haml
index ea27f72764f..fc318de4c42 100644
--- a/app/views/notify/access_token_about_to_expire_email.html.haml
+++ b/app/views/notify/access_token_about_to_expire_email.html.haml
@@ -8,4 +8,4 @@
%li= token
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
- = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
+ = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_about_to_expire_email.text.erb b/app/views/notify/access_token_about_to_expire_email.text.erb
index dc9b1379e47..39608f0d6bd 100644
--- a/app/views/notify/access_token_about_to_expire_email.text.erb
+++ b/app/views/notify/access_token_about_to_expire_email.text.erb
@@ -6,4 +6,4 @@
- <%= token %>
<% end %>
-<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %>
+<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %>
diff --git a/app/views/notify/access_token_expired_email.html.haml b/app/views/notify/access_token_expired_email.html.haml
index b26431cce91..1e7c07c2282 100644
--- a/app/views/notify/access_token_expired_email.html.haml
+++ b/app/views/notify/access_token_expired_email.html.haml
@@ -4,4 +4,4 @@
= _('One or more of your personal access tokens has expired.')
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
- = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
+ = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_expired_email.text.erb b/app/views/notify/access_token_expired_email.text.erb
index d44f993d094..4dc67e85dc2 100644
--- a/app/views/notify/access_token_expired_email.text.erb
+++ b/app/views/notify/access_token_expired_email.text.erb
@@ -2,4 +2,4 @@
<%= _('One or more of your personal access tokens has expired.') %>
-<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %>
+<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %>
diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml
index 45b002757e3..6382718480f 100644
--- a/app/views/notify/in_product_marketing_email.html.haml
+++ b/app/views/notify/in_product_marketing_email.html.haml
@@ -3,7 +3,7 @@
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
- %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css" }
+ %link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css", data: { premailer: 'ignore' } }
%title= message.subject
:css
/* CLIENT-SPECIFIC STYLES */
diff --git a/app/views/notify/send_admin_notification.html.haml b/app/views/notify/send_admin_notification.html.haml
new file mode 100644
index 00000000000..f7f1528f332
--- /dev/null
+++ b/app/views/notify/send_admin_notification.html.haml
@@ -0,0 +1,7 @@
+= simple_format @body
+
+\----
+
+%p
+ Don't want to receive updates from GitLab administrators?
+ = link_to 'Unsubscribe', @unsubscribe_url
diff --git a/app/views/notify/send_admin_notification.text.haml b/app/views/notify/send_admin_notification.text.haml
new file mode 100644
index 00000000000..bfacbd3dcb2
--- /dev/null
+++ b/app/views/notify/send_admin_notification.text.haml
@@ -0,0 +1,6 @@
+= h @body
+
+\-----
+
+Don't want to receive updates from GitLab administrators?
+Unsubscribe here: #{@unsubscribe_url}
diff --git a/app/views/notify/send_unsubscribed_notification.html.haml b/app/views/notify/send_unsubscribed_notification.html.haml
new file mode 100644
index 00000000000..9f68feeaa31
--- /dev/null
+++ b/app/views/notify/send_unsubscribed_notification.html.haml
@@ -0,0 +1,2 @@
+%p
+ You have been unsubscribed from receiving GitLab administrator notifications.
diff --git a/app/views/notify/send_unsubscribed_notification.text.haml b/app/views/notify/send_unsubscribed_notification.text.haml
new file mode 100644
index 00000000000..5edc1ddcdae
--- /dev/null
+++ b/app/views/notify/send_unsubscribed_notification.text.haml
@@ -0,0 +1 @@
+You have been unsubscribed from receiving GitLab administrator notifications.
diff --git a/app/views/notify/user_deactivated_email.html.haml b/app/views/notify/user_deactivated_email.html.haml
new file mode 100644
index 00000000000..a9262cab219
--- /dev/null
+++ b/app/views/notify/user_deactivated_email.html.haml
@@ -0,0 +1,17 @@
+= email_default_heading(_('Hello %{name},') % { name: @name })
+%p
+ = _('Your account has been deactivated. You will not be able to: ')
+%ul
+ %li
+ = _('Access Git repositories or the API.')
+ %li
+ = _('Receive any notifications from GitLab.')
+ %li
+ = _('Use slash commands.')
+%p
+ - gitlab_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: root_url }
+ - link_end = '</a>'.html_safe
+ = _('To reactivate your account, %{gitlab_link_start}sign in to GitLab.%{link_end}').html_safe % { gitlab_link_start: gitlab_link_start, link_end: link_end}
+
+%p
+ = _('Please contact your GitLab administrator if you think this is an error.')
diff --git a/app/views/notify/user_deactivated_email.text.erb b/app/views/notify/user_deactivated_email.text.erb
new file mode 100644
index 00000000000..9e7d00f4ad1
--- /dev/null
+++ b/app/views/notify/user_deactivated_email.text.erb
@@ -0,0 +1,10 @@
+<%= _('Hello %{name},') % { name: @name } %>
+
+<%= _('Your account has been deactivated. You will not be able to: ') %>
+ - <%= _('Access Git repositories or the API.') %>
+ - <%= _('Receive any notifications from GitLab.') %>
+ - <%= _('Use slash commands.') %>
+
+<%= _('To reactivate your account, sign in to GitLab at %{gitlab_url}.') % { gitlab_url: root_url } %>
+
+<%= _('Please contact your GitLab administrator if you think this is an error.') %>
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index c4de47f276c..809dc3320ff 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -2,16 +2,20 @@
- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
- .gl-alert.gl-alert-info.gl-my-5
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ = render 'shared/global_alert',
+ variant: :info,
+ alert_class: 'gl-my-5',
+ is_contained: true,
+ dismissible: false do
.gl-alert-body
= s_('Profiles|Some options are unavailable for LDAP accounts')
- if params[:two_factor_auth_enabled_successfully]
- .gl-alert.gl-alert-success.gl-my-5{ role: 'alert' }
- = sprite_icon('check-circle', size: 16, css_class: 'gl-alert-icon gl-alert-icon-no-title')
- %button.gl-alert-dismiss.js-close-2fa-enabled-success-alert{ type: 'button', aria: { label: _('Close') } }
- = sprite_icon('close', size: 16)
+ = render 'shared/global_alert',
+ variant: :success,
+ alert_class: 'gl-my-5',
+ is_contained: true,
+ close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body
= html_escape(_('You have set up 2FA for your account! If you lose access to your 2FA device, you can use your recovery codes to access your account. Alternatively, if you upload an SSH key, you can %{anchorOpen}use that key to generate additional recovery codes%{anchorClose}.')) % { anchorOpen: '<a href="%{href}">'.html_safe % { href: help_page_path('user/profile/account/two_factor_authentication', anchor: 'generate-new-recovery-codes-using-ssh') }, anchorClose: '</a>'.html_safe }
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 69b8d2ddafe..584bd44e386 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -11,8 +11,8 @@
%h5.gl-mt-0
= _('Add an SSH key')
%p.profile-settings-content
- - generate_link_url = help_page_path("ssh/README", anchor: 'generate-an-ssh-key-pair')
- - existing_link_url = help_page_path("ssh/README", anchor: 'see-if-you-have-an-existing-ssh-key-pair')
+ - generate_link_url = help_page_path("ssh/index", anchor: 'generate-an-ssh-key-pair')
+ - existing_link_url = help_page_path("ssh/index", anchor: 'see-if-you-have-an-existing-ssh-key-pair')
- generate_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_link_url }
- existing_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: existing_link_url }
= _('To add an SSH key you need to %{generate_link_start}generate one%{link_end} or use an %{existing_link_start}existing key%{link_end}.').html_safe % { generate_link_start: generate_link_start, existing_link_start: existing_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 3661b93e33c..7c1f28345fd 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -24,7 +24,8 @@
type: type,
path: profile_personal_access_tokens_path,
token: @personal_access_token,
- scopes: @scopes
+ scopes: @scopes,
+ help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
= render 'shared/access_tokens/table',
type: type,
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 0adad6b64a0..e52a345bd86 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -128,29 +128,27 @@
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'select2'
- - if Feature.enabled?(:user_time_settings)
- .col-sm-12
- %hr
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0= s_('Preferences|Time preferences')
- %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
- .col-lg-8
- .form-group
- %h5= s_('Preferences|Time format')
- .checkbox-icon-inline-wrapper
- - time_format_label = capture do
- = s_('Preferences|Display time in 24-hour format')
- = f.check_box :time_format_in_24h
- = f.label :time_format_in_24h do
- = time_format_label
- %h5= s_('Preferences|Time display')
- .checkbox-icon-inline-wrapper
- - time_display_label = capture do
- = s_('Preferences|Use relative times')
- = f.check_box :time_display_relative
- = f.label :time_display_relative do
- = time_display_label
- .form-text.text-muted
- = s_('Preferences|For example: 30 mins ago.')
+ .col-sm-12
+ %hr
+ .row.js-preferences-form.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar#time-preferences
+ %h4.gl-mt-0
+ = s_('Preferences|Time preferences')
+ %p
+ = s_('Preferences|Configure how dates and times display for you.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'time-preferences'), target: '_blank'
+ .col-lg-8
+ .form-group.form-check
+ = f.check_box :time_display_relative, class: 'form-check-input'
+ = f.label :time_display_relative, class: 'form-check-label' do
+ = s_('Preferences|Use relative times')
+ .form-text.text-muted
+ = s_('Preferences|For example: 30 minutes ago.')
+ - if Feature.enabled?(:user_time_settings)
+ .form-group.form-check
+ = f.check_box :time_format_in_24h, class: 'form-check-input'
+ = f.label :time_format_in_24h, class: 'form-check-label' do
+ = s_('Preferences|Display time in 24-hour format')
#js-profile-preferences-app{ data: data_attributes }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 411954aed6a..0328fa5c282 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -72,9 +72,8 @@
.checkbox-icon-inline-wrapper
= status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"]
.gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name')
- - if Feature.enabled?(:user_time_settings)
- .col-lg-12
- %hr
+ .col-lg-12
+ %hr
.row.user-time-preferences.js-search-settings-section
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0= s_("Profiles|Time settings")
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 3cd571c23d3..927b6d4edef 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,5 +1,6 @@
+- breadcrumb_title _('Two-Factor Authentication')
- page_title _('Two-Factor Authentication'), _('Account')
-- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path)
+- add_to_breadcrumbs _('Account'), profile_account_path
- @content_class = "limit-container-width" unless fluid_layout
- webauthn_enabled = Feature.enabled?(:webauthn)
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index f9222387e97..7395495b537 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -9,3 +9,4 @@
= render 'shared/auto_devops_implicitly_enabled_banner', project: project
= render_if_exists 'projects/above_size_limit_warning', project: project
= render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
+ = render_if_exists 'projects/terraform_banner', project: project
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 26291c0358e..86172499118 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -47,6 +47,10 @@
= cache_if(cache_enabled, [@project, :buttons, current_user, @notification_setting], expires_in: 1.day) do
.project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5
- if current_user
+ - if current_user.admin?
+ = link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: s_('View project in admin area'),
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('admin')
.gl-display-flex.gl-align-items-start.gl-mr-3
- if @notification_setting
.js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id } }
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index e6ded3ad912..2055f1c7f60 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -7,7 +7,7 @@
= _("Import project from")
.import-buttons
- if gitlab_project_import_enabled?
- .import_gitlab_project.has-tooltip{ data: { container: 'body' } }
+ .import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } }
= link_to new_import_gitlab_project_path, class: 'gl-button btn-default btn btn_import_gitlab_project', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do
.gl-button-icon
= sprite_icon('tanuki')
diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members_empty_project.html.haml
index ab630d34501..ee2215b0fbb 100644
--- a/app/views/projects/_invite_members.html.haml
+++ b/app/views/projects/_invite_members_empty_project.html.haml
@@ -6,7 +6,8 @@
.js-invite-members-trigger{ data: { variant: 'confirm',
classes: 'gl-mb-8 gl-xs-w-full',
display_text: s_('InviteMember|Invite members'),
+ trigger_source: 'project-empty-page',
event: 'click_button',
label: 'invite_members_empty_project' } }
-= render 'shared/issuable/invite_members_trigger', project: @project
+= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml
index 00f823b9016..5dd6ec0addf 100644
--- a/app/views/projects/_invite_members_modal.html.haml
+++ b/app/views/projects/_invite_members_modal.html.haml
@@ -1,7 +1,8 @@
-- if can_invite_members_for_project?(project)
- .js-invite-members-modal{ data: { id: project.id,
- name: project.name,
- is_project: 'true',
- access_levels: ProjectMember.access_level_roles.to_json,
- default_access_level: Gitlab::Access::GUEST,
- help_link: help_page_url('user/permissions') } }
+- return unless can_import_members?
+
+.js-invite-members-modal{ data: { id: project.id,
+ name: project.name,
+ is_project: 'true',
+ access_levels: ProjectMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
index 694d192d079..fbc58283cbf 100644
--- a/app/views/projects/_merge_request_merge_checks_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -8,7 +8,7 @@
= form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
= s_('ProjectSettings|Pipelines must succeed')
.text-secondary
- - configuring_pipelines_for_merge_requests_help_link_url = help_page_path('ci/merge_request_pipelines/index.md', anchor: 'configuring-pipelines-for-merge-requests')
+ - configuring_pipelines_for_merge_requests_help_link_url = help_page_path('ci/pipelines/merge_request_pipelines.md', anchor: 'configure-pipelines-for-merge-requests')
- configuring_pipelines_for_merge_requests_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configuring_pipelines_for_merge_requests_help_link_url }
= s_('ProjectSettings|To enable this feature, configure pipelines. %{link_start}How to configure pipelines for merge requests?%{link_end}').html_safe % { link_start: configuring_pipelines_for_merge_requests_help_link_start, link_end: '</a>'.html_safe }
.form-check.mb-2
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 66fc313213a..55696337bc1 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -23,7 +23,7 @@
display_path: true,
extra_group: namespace_id),
{},
- { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
+ { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "", qa_selector: "select_namespace_dropdown" }})
- else
.input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
index e991a9b0ec7..cb0ec9f19c8 100644
--- a/app/views/projects/_remove.html.haml
+++ b/app/views/projects/_remove.html.haml
@@ -3,7 +3,7 @@
.sub-section
%h4.danger-title= _('Delete project')
%p
- %strong= _('Deleting the project will delete its repository and all related resources including issues, merge requests, etc.')
+ %strong= _('Deleting the project will delete its repository and all related resources, including issues and merge requests.')
= link_to _('Learn more.'), help_page_path('user/project/settings/index', anchor: 'removing-a-fork-relationship'), target: '_blank', rel: 'noopener noreferrer'
%p
%strong= _('Deleted projects cannot be restored!')
diff --git a/app/views/projects/_terraform_banner.html.haml b/app/views/projects/_terraform_banner.html.haml
new file mode 100644
index 00000000000..a30c4a2d624
--- /dev/null
+++ b/app/views/projects/_terraform_banner.html.haml
@@ -0,0 +1,5 @@
+- @content_class = "container-limited limit-container-width" unless fluid_layout
+
+- if show_terraform_banner?(project)
+ .container-fluid{ class: @content_class }
+ .js-terraform-notification{ data: { project_id: project.id } }
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index cba63d5e6d6..8fe9c9e5c52 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -1,5 +1,4 @@
- page_title _("Blame"), @blob.path, @ref
-- link_icon = sprite_icon("link", size: 12)
#blob-content-holder.tree-holder
= render "projects/blob/breadcrumb", blob: @blob, blame: true
@@ -48,8 +47,8 @@
%td.line-numbers
- (current_line...(current_line + line_count)).each do |i|
%a.diff-line-num.gl-justify-content-end{ href: "#L#{i}", id: "L#{i}", 'data-line-number' => i, class: "gl-display-flex!" }
- = link_icon
- = i
+ .file-line-num
+ = i
\
%td.lines
diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml
index ef1fe25ba1b..8f1c2f93162 100644
--- a/app/views/projects/blob/_pipeline_tour_success.html.haml
+++ b/app/views/projects/blob/_pipeline_tour_success.html.haml
@@ -1,6 +1,6 @@
.js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name,
'go-to-pipelines-path': project_pipelines_path(@project),
'project-merge-requests-path': project_merge_requests_path(@project),
- 'example-link': help_page_path('ci/examples/README.md', anchor: 'gitlab-cicd-examples'),
+ 'example-link': help_page_path('ci/examples/index.md', anchor: 'gitlab-cicd-examples'),
'code-quality-link': help_page_path('user/project/merge_requests/code_quality'),
'human-access': @project.team.human_max_access(current_user&.id) } }
diff --git a/app/views/projects/blob/viewers/_csv.html.haml b/app/views/projects/blob/viewers/_csv.html.haml
new file mode 100644
index 00000000000..3a58bc9902c
--- /dev/null
+++ b/app/views/projects/blob/viewers/_csv.html.haml
@@ -0,0 +1 @@
+.file-content#js-csv-viewer{ data: { data: viewer.blob.data } }
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 6cb2c435a30..6de50d48721 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -2,10 +2,10 @@
- default_ref = params[:ref] || @project.default_branch
- if @error
- .gl-alert.gl-alert-danger
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ = render 'shared/global_alert',
+ variant: :danger,
+ close_button_class: 'js-close',
+ is_contained: true do
.gl-alert-body
= @error
%h3.page-title
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index c1f6cfc40c3..3c9762e200a 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,6 +1,6 @@
- page_title _("Value Stream Analytics")
- add_page_specific_style 'page_bundles/cycle_analytics'
- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
-- initial_data = { request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs)
+- initial_data = { project_id: @project.id, group_path: @project.group&.path, request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs)
#js-cycle-analytics{ data: initial_data }
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 9e9fc08dac0..68ca318e88c 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -17,7 +17,7 @@
- else
.form-group
= f.label :default_branch, "Default branch", class: 'label-bold'
- = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide'})
+ = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide', data: { qa_selector: 'default_branch_dropdown' }})
.form-group
.form-check
@@ -28,4 +28,4 @@
= _("When merge requests and commits in the default branch close, any issues they reference also close.")
= link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
- = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 30b0631b465..1d9b1b13d5c 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -1,9 +1,8 @@
-.gl-alert.gl-alert-warning.gl-mb-5
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
- = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon')
- %h4.gl-alert-title
- = _("Too many changes to show.")
+= render 'shared/global_alert',
+ title: _('Too many changes to show.'),
+ variant: :warning,
+ is_contained: true,
+ alert_class: 'gl-mb-5' do
.gl-alert-body
= html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
.gl-alert-actions
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 027b81d6c68..0fda74a3be5 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -7,7 +7,7 @@
= render "home_panel"
= render "archived_notice", project: @project
-= render "invite_members" if can_import_members?
+= render 'invite_members_empty_project' if can_import_members?
%h4.gl-mt-0.gl-mb-3
= _('The repository for this project is empty')
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index 1549f5cf6d6..f71c97c2a8f 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -8,9 +8,6 @@
project_id: @project.id,
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
- user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
- show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scoping-environments-with-specs'),
feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user) } }
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 0c15796b667..30e2e9f19d9 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -1,10 +1,11 @@
- page_title _("Fork project")
- if @forked_project && !@forked_project.saved?
- .gl-alert.gl-alert-danger.gl-mt-5
- = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon')
- %h4.gl-alert-title
- = sprite_icon('fork')
- = _("Fork Error!")
+ = render 'shared/global_alert',
+ title: _('Fork Error!'),
+ variant: :danger,
+ alert_class: 'gl-mt-5',
+ is_contained: true,
+ dismissible: false do
.gl-alert-body
%p
= _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) }
@@ -14,9 +15,9 @@
&ndash;
- error = @forked_project.errors.full_messages.first
- if error.include?("already been taken")
- = _("Name has already been taken")
+ = _('Name has already been taken')
- else
= error
.gl-alert-actions
- = link_to _("Try to fork again"), new_project_fork_path(@project), title: _("Fork"), class: "btn gl-alert-action btn-info btn-md gl-button"
+ = link_to _('Try to fork again'), new_project_fork_path(@project), title: _("Fork"), class: "btn gl-alert-action btn-info btn-md gl-button"
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index 0716eda79a8..8848fbae9cb 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,6 +1,6 @@
- page_title s_("ForkProject|Fork project")
-- if Feature.enabled?(:fork_project_form)
+- if Feature.enabled?(:fork_project_form, @project, default_enabled: :yaml)
#fork-groups-mount-element{ data: { fork_illustration: image_path('illustrations/project-create-new-sm.svg'),
endpoint: new_project_fork_path(@project, format: :json),
new_group_path: new_group_path,
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index 3c0664e4d5f..29296ce23c9 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -1,7 +1,7 @@
.js-jira-import-root{ data: { project_path: @project.full_path,
issues_path: project_issues_path(@project),
jira_integration_path: edit_project_service_path(@project, :jira),
- is_jira_configured: @project.jira_service&.active? && @project.jira_service&.valid_connection?.to_s,
+ is_jira_configured: @project.jira_integration&.configured?.to_s,
in_progress_illustration: image_path('illustrations/export-import.svg'),
project_id: @project.id,
setup_illustration: image_path('illustrations/manual_action.svg') } }
diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
index 9b142b08574..662270fb8e1 100644
--- a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
+++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml
@@ -2,9 +2,10 @@
- service_desk_link_url = help_page_path('user/project/service_desk')
- service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url }
-.hide.gl-alert.gl-alert-warning.js-alert-moved-from-service-desk-warning.gl-mt-5{ role: 'alert' }
- = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', css_class: 'gl-icon')
+= render 'shared/global_alert',
+ variant: :warning,
+ is_contained: true,
+ close_button_class: 'js-close',
+ alert_class: 'hide js-alert-moved-from-service-desk-warning gl-mt-5' do
.gl-alert-body.gl-mr-3
= s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe }
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 3e8442eee86..ecf10cd4821 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -9,7 +9,7 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} issues")
.js-jira-issues-import-status{ data: { can_edit: can?(current_user, :admin_project, @project).to_s,
- is_jira_configured: @project.jira_service.present?.to_s,
+ is_jira_configured: @project.jira_integration.present?.to_s,
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index a465f59c559..6c6f98e0b20 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -4,4 +4,4 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
= render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
-= render 'shared/issuable/invite_members_trigger', project: @project
+= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/mattermosts/_no_teams.html.haml b/app/views/projects/mattermosts/_no_teams.html.haml
index f375222e311..adef11f315a 100644
--- a/app/views/projects/mattermosts/_no_teams.html.haml
+++ b/app/views/projects/mattermosts/_no_teams.html.haml
@@ -15,4 +15,4 @@
and try again.
%hr
.clearfix
- = link_to 'Go back', edit_project_service_path(@project, @service), class: 'gl-button btn btn-lg float-right'
+ = link_to 'Go back', edit_project_service_path(@project, @integration), class: 'gl-button btn btn-lg float-right'
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index ea04a55a77c..4109fdfc13b 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -2,7 +2,7 @@
This service will be installed on the Mattermost instance at
%strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host
%hr
-= form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input'} ) do |f|
+= form_for(:mattermost, method: :post, url: project_mattermost_path(@project), html: { class: 'js-requires-input' }) do |f|
%h4 Team
%p
= @teams.one? ? 'The team' : 'Select the team'
@@ -42,5 +42,5 @@
%hr
.clearfix
.float-right
- = link_to 'Cancel', edit_project_service_path(@project, @service), class: 'gl-button btn btn-lg'
+ = link_to 'Cancel', edit_project_service_path(@project, @integration), class: 'gl-button btn btn-lg'
= f.submit 'Install', class: 'gl-button btn btn-success btn-lg'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 691ce8dc5fc..c4ee522bfa7 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -96,5 +96,5 @@
#js-review-bar
-= render 'shared/issuable/invite_members_trigger', project: @project
+= render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index dfb9defb91c..5f2057df4aa 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,27 +1,25 @@
= form_for [@project, @milestone],
html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
- .row
- .col-md-6
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :title, _('Title')
- .col-sm-10
- = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true
- .form-group.row.milestone-description
- .col-form-label.col-sm-2
- = f.label :description, _('Description')
- .col-sm-10
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- qa_selector: 'milestone_description_field',
- supports_autocomplete: true,
- placeholder: _('Write milestone description...')
- = render 'shared/notes/hints'
- .clearfix
- .error-alert
- = render 'shared/milestones/form_dates', f: f
+ .form-group.row
+ .col-form-label.col-sm-2
+ = f.label :title, _('Title')
+ .col-sm-10
+ = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true
+ = render 'shared/milestones/form_dates', f: f
+ .form-group.row.milestone-description
+ .col-form-label.col-sm-2
+ = f.label :description, _('Description')
+ .col-sm-10
+ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do
+ = render 'shared/zen', f: f, attr: :description,
+ classes: 'note-textarea',
+ qa_selector: 'milestone_description_field',
+ supports_autocomplete: true,
+ placeholder: _('Write milestone description...')
+ = render 'shared/notes/hints'
+ .clearfix
+ .error-alert
.form-actions
- if @milestone.new_record?
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 99fe64723d9..dbde3346b81 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -12,19 +12,17 @@
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count == 0
- .gl-alert.gl-alert-info.gl-mt-3.gl-mb-5{ data: { testid: 'no-issues-alert' } }
- .gl-alert-container
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- %span= _('Assign some issues to this milestone.')
-- elsif @milestone.complete? && @milestone.active?
- .gl-alert.gl-alert-success.gl-mt-3.gl-mb-5{ data: { testid: 'all-issues-closed-alert' } }
- .gl-alert-container
- = sprite_icon('check-circle', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- .gl-alert-body
- %span= _('All issues for this milestone are closed. You may close this milestone now.')
+ = render 'shared/global_alert',
+ variant: :info,
+ dismissible: false,
+ is_contained: true,
+ alert_data: { testid: 'no-issues-alert' },
+ alert_class: 'gl-mt-3 gl-mb-5' do
+ .gl-alert-body
+ = _('Assign some issues to this milestone.')
+- else
+ = render 'shared/milestones/milestone_complete_alert', milestone: @milestone do
+ = _('All issues for this milestone are closed. You may close this milestone now.')
= render 'shared/milestones/tabs', milestone: @milestone
= render 'shared/milestones/sidebar', milestone: @milestone, project: @project, affix_offset: 153
diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml
index aeca3f5b3e3..76eb22109a4 100644
--- a/app/views/projects/packages/packages/show.html.haml
+++ b/app/views/projects/packages/packages/show.html.haml
@@ -6,23 +6,7 @@
.row
.col-12
- #js-vue-packages-detail{ data: { package: package_from_presenter(@package),
- can_delete: can?(current_user, :destroy_package, @project).to_s,
- svg_path: image_path('illustrations/no-packages.svg'),
- npm_path: package_registry_instance_url(:npm),
- npm_help_path: help_page_path('user/packages/npm_registry/index'),
- maven_path: package_registry_project_url(@project.id, :maven),
- maven_help_path: help_page_path('user/packages/maven_repository/index'),
- conan_path: package_registry_project_url(@project.id, :conan),
- conan_help_path: help_page_path('user/packages/conan_repository/index'),
- nuget_path: nuget_package_registry_url(@project.id),
- nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
- pypi_path: pypi_registry_url(@project.id),
- pypi_setup_path: package_registry_project_url(@project.id, :pypi),
- pypi_help_path: help_page_path('user/packages/pypi_repository/index'),
- composer_path: composer_registry_url(@project&.group&.id),
- composer_help_path: help_page_path('user/packages/composer_repository/index'),
- project_name: @project.name,
- project_list_url: project_packages_path(@project),
- group_list_url: @project.group ? group_packages_path(@project.group) : '',
- composer_config_repository_name: composer_config_repository_name(@project.group&.id)} }
+ - if Feature.enabled?(:package_details_apollo)
+ #js-vue-packages-detail-new{ data: package_details_data(@project) }
+ - else
+ #js-vue-packages-detail{ data: package_details_data(@project, @package) }
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
index 483f192109b..2db44528d51 100644
--- a/app/views/projects/pages/_pages_settings.html.haml
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -1,5 +1,8 @@
= form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f|
- = render_if_exists 'shared/pages/max_pages_size_input', form: f
+ - if can?(current_user, :update_max_pages_size)
+ = render_if_exists 'shared/pages/max_pages_size_input', form: f
+ .gl-mt-3
+ = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
@@ -15,4 +18,4 @@
= s_("GitLabPages|When enabled, all attempts to visit your website through HTTP are automatically redirected to HTTPS using a response with status code 301. Requires a valid certificate for all domains. %{docs_link_start}Learn more.%{link_end}").html_safe % { docs_link_start: docs_link_start, link_end: link_end }
.gl-mt-3
- = f.submit s_('GitLabPages|Save'), class: 'btn btn-confirm gl-button'
+ = f.submit s_('GitLabPages|Save changes'), class: 'btn btn-confirm gl-button'
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index e56a240c487..c1d48992500 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -78,7 +78,7 @@
= build_summary(build)
#js-tab-dag.tab-pane
- #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
+ #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/index.md', anchor: 'needs')} }
#js-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 9669b2e72dc..ae76d4905e0 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -22,7 +22,7 @@
"ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project),
"reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project),
"has-gitlab-ci" => has_gitlab_ci?(@project).to_s,
- "add-ci-yml-path" => can?(current_user, :create_pipeline, @project) && @project.present(current_user: current_user).add_ci_yml_path,
- "suggested-ci-templates" => experiment_suggested_ci_templates.to_json,
+ "pipeline-editor-path" => can?(current_user, :create_pipeline, @project) && project_ci_pipeline_editor_path(@project),
+ "suggested-ci-templates" => suggested_ci_templates.to_json,
"code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path,
"ci-runner-settings-path" => project_settings_ci_cd_path(@project, ci_runner_templates: true, anchor: 'js-runners-settings') } }
diff --git a/app/views/projects/prometheus/metrics/edit.html.haml b/app/views/projects/prometheus/metrics/edit.html.haml
index 15a9c922ca6..d308824571e 100644
--- a/app/views/projects/prometheus/metrics/edit.html.haml
+++ b/app/views/projects/prometheus/metrics/edit.html.haml
@@ -1,6 +1,6 @@
- add_to_breadcrumbs _("Settings"), edit_project_path(@project)
- add_to_breadcrumbs _("Integrations"), project_settings_integrations_path(@project)
-- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, PrometheusService)
+- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, ::Integrations::Prometheus)
- breadcrumb_title s_('Metrics|Edit metric')
- page_title @metric.title, s_('Metrics|Edit metric')
= render 'form', project: @project, metric: @metric
diff --git a/app/views/projects/prometheus/metrics/new.html.haml b/app/views/projects/prometheus/metrics/new.html.haml
index fa925d090cb..8415ec9ee41 100644
--- a/app/views/projects/prometheus/metrics/new.html.haml
+++ b/app/views/projects/prometheus/metrics/new.html.haml
@@ -1,6 +1,6 @@
- add_to_breadcrumbs _("Settings"), edit_project_path(@project)
- add_to_breadcrumbs _("Integrations"), project_settings_integrations_path(@project)
-- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, PrometheusService)
+- add_to_breadcrumbs "Prometheus", edit_project_service_path(@project, ::Integrations::Prometheus)
- breadcrumb_title s_('Metrics|New metric')
- page_title s_('Metrics|New metric')
= render 'form', project: @project, metric: @metric
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index ad131b22924..57fc9a16c0a 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -10,7 +10,7 @@
.col-md-10
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
.form-text.text-muted
- - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'wildcard-protected-branches')
+ - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard')
- wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
= (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe
.form-group.row
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 08246a173d8..2e9a9357fb0 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -3,19 +3,16 @@
%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_branches_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- Protected branches
+ = s_("ProtectedBranch|Protected branches")
%button.btn.gl-button.btn-default.js-settings-toggle.qa-expand-protected-branches{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- Keep stable branches secure, and force developers to use merge requests. #{link_to "What are protected branches?", help_page_path("user/project/protected_branches")}
+ = s_("ProtectedBranch|Keep stable branches secure and force developers to use merge requests.")
+ = link_to s_("ProtectedBranch|What are protected branches?"), help_page_path("user/project/protected_branches")
.settings-content
%p
- By default, protected branches protect your code and:
- %ul
- %li Allow only users with Maintainer #{link_to "permissions", help_page_path("user/permissions")} to create new protected branches.
- %li Allow only users with Maintainer permissions to push code.
- %li Prevent <strong>anyone</strong> from #{link_to "force-pushing", help_page_path('topics/git/git_rebase', anchor: 'force-push')} to the branch.
- %li Prevent <strong>anyone</strong> from deleting the branch.
+ = s_("ProtectedBranch|By default, protected branches restrict who can modify the branch.")
+ = link_to s_("ProtectedBranch|Learn more."), help_page_path("user/project/protected_branches", anchor: "who-can-modify-a-protected-branch")
- if can? current_user, :admin_project, @project
= content_for :create_protected_branch
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index aab4d366605..fe63f921780 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -3,18 +3,16 @@
%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_tag_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
- Protected tags
+ = s_("ProtectedTag|Protected tags")
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
- Limit access to creating and updating tags. #{link_to "What are protected tags?", help_page_path("user/project/protected_tags")}
+ = s_("ProtectedTag|Limit access to creating and updating tags.")
+ = link_to s_("ProtectedTag|What are protected tags?"), help_page_path("user/project/protected_tags")
.settings-content
%p
- By default, protected tags protect your code and:
- %ul
- %li Allow only users with Maintainer #{link_to "permissions", help_page_path("user/permissions")} to create tags.
- %li Prevent <strong>anyone</strong> from updating tags.
- %li Prevent <strong>anyone</strong> from deleting tags.
+ = s_("ProtectedTag|By default, protected branches restrict who can modify the tag.")
+ = link_to s_("ProtectedTag|Learn more."), help_page_path("user/project/protected_tags", anchor: "who-can-modify-a-protected-tag")
- if can? current_user, :admin_project, @project
= yield :create_protected_tag
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
deleted file mode 100644
index a4d4a1bb2dd..00000000000
--- a/app/views/projects/registry/settings/_index.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-#js-registry-settings{ data: { project_id: @project.id,
- project_path: @project.full_path,
- cadence_options: cadence_options.to_json,
- keep_n_options: keep_n_options.to_json,
- older_than_options: older_than_options.to_json,
- is_admin: current_user&.admin.to_s,
- admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
- enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s,
- tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } }
diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml
index 4d5b8cc80f7..9ddf2201fad 100644
--- a/app/views/projects/releases/index.html.haml
+++ b/app/views/projects/releases/index.html.haml
@@ -1,3 +1,5 @@
- page_title _('Releases')
+- if use_startup_query_for_index_page?
+ - add_page_startup_graphql_call('releases/all_releases', index_page_startup_query_variables)
#js-releases-page{ data: data_for_releases_page }
diff --git a/app/views/projects/runners/_index.html.haml b/app/views/projects/runners/_settings.html.haml
index a02bdac442b..a02bdac442b 100644
--- a/app/views/projects/runners/_index.html.haml
+++ b/app/views/projects/runners/_settings.html.haml
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index 210cc414007..eb376ff7960 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -2,22 +2,26 @@
= _('Specific runners')
.bs-callout.help-callout
- = _('These runners are specific to this project.')
- %hr
- = render partial: 'ci/runner/how_to_setup_runner_automatically',
- locals: { type: 'specific',
- clusters_path: project_clusters_path(@project) }
- - if params[:ci_runner_templates]
+ - if valid_runner_registrars.include?('project')
+ = _('These runners are specific to this project.')
%hr
- = render partial: 'ci/runner/setup_runner_in_aws',
- locals: { registration_token: @project.runners_token }
- %hr
- = render partial: 'ci/runner/how_to_setup_runner',
- locals: { registration_token: @project.runners_token,
- type: 'specific',
- reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
- project_path: @project.path_with_namespace,
- group_path: '' }
+ = render partial: 'ci/runner/how_to_setup_runner_automatically',
+ locals: { type: s_('Runners|specific'),
+ clusters_path: project_clusters_path(@project) }
+ - if params[:ci_runner_templates]
+ %hr
+ = render partial: 'ci/runner/setup_runner_in_aws',
+ locals: { registration_token: @project.runners_token }
+ %hr
+ = render partial: 'ci/runner/how_to_setup_runner',
+ locals: { registration_token: @project.runners_token,
+ type: s_('Runners|specific'),
+ reset_token_url: reset_registration_token_namespace_project_settings_ci_cd_path,
+ project_path: @project.path_with_namespace,
+ group_path: '' }
+ - else
+ = _('Please contact an admin to register runners.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'runner-registration'), target: '_blank', rel: 'noopener noreferrer'
%hr
diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml
index 4d6feb9de6d..d4a85893fa4 100644
--- a/app/views/projects/security/configuration/show.html.haml
+++ b/app/views/projects/security/configuration/show.html.haml
@@ -1,4 +1,6 @@
- breadcrumb_title _("Security Configuration")
- page_title _("Security Configuration")
+- redesign_enabled = ::Feature.enabled?(:security_configuration_redesign, @project, default_enabled: :yaml)
+- @content_class = "limit-container-width" unless fluid_layout || !redesign_enabled
#js-security-configuration-static{ data: { project_path: @project.full_path, upgrade_path: security_upgrade_path } }
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 1bf252b6282..52ef2e7d1ee 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -33,12 +33,17 @@
= render 'shared/access_tokens/form',
type: type,
path: project_settings_access_tokens_path(@project),
+ project: @project,
token: @project_access_token,
scopes: @scopes,
- prefix: :project_access_token
+ access_levels: ProjectMember.access_level_roles,
+ default_access_level: Gitlab::Access::MAINTAINER,
+ prefix: :project_access_token,
+ help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'limiting-scopes-of-a-project-access-token')
= render 'shared/access_tokens/table',
active_tokens: @active_project_access_tokens,
+ project: @project,
type: type,
type_plural: type_plural,
revoke_route_helper: ->(token) { revoke_namespace_project_settings_access_token_path(id: token) },
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 68e4bed8b9a..8563f28eb33 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -6,7 +6,7 @@
- kubernetes_cluster_path = help_page_path('user/project/clusters/index')
- kubernetes_cluster_link_start = link_start % { url: kubernetes_cluster_path }
-- base_domain_path = help_page_path('user/project/clusters/index', anchor: 'base-domain')
+- base_domain_path = help_page_path('user/project/clusters/gitlab_managed_clusters', anchor: 'base-domain')
- base_domain_link_start = link_start % { url: base_domain_path }
.row
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index c89c9879f4b..a91c12d01ad 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -10,7 +10,7 @@
%strong= _("Public pipelines")
.form-text.text-muted
= _("Allow public access to pipelines and job details, including output logs and artifacts.")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'change-which-users-can-view-your-pipelines'), target: '_blank'
.form-group
.form-check
@@ -36,7 +36,7 @@
= f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
= html_escape(_("The name of the CI/CD configuration file. A path relative to the root directory is optional (for example %{code_open}my/path/.myfile.yml%{code_close}).")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'custom-cicd-configuration-file'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank'
%hr
.form-group
@@ -44,7 +44,7 @@
= _("Git strategy")
%p
= _("Choose which Git strategy to use when fetching the project.")
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'git-strategy'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'choose-the-default-git-strategy'), target: '_blank'
.form-check
= f.radio_button :build_allow_git_fetch, 'false', { class: 'form-check-input' }
= f.label :build_allow_git_fetch_false, class: 'form-check-label' do
@@ -66,7 +66,7 @@
= form.number_field :default_git_depth, { class: 'form-control gl-form-input', min: 0, max: 1000 }
%p.form-text.text-muted
= html_escape(_('The number of changes to fetch from GitLab when cloning a repository. Lower values can speed up pipeline execution. Set to %{code_open}0%{code_close} or blank to fetch all branches and tags for each job')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'git-shallow-clone'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'limit-the-number-of-changes-fetched-during-clone'), target: '_blank'
%hr
.form-group
@@ -74,7 +74,7 @@
= f.text_field :build_timeout_human_readable, class: 'form-control gl-form-input'
%p.form-text.text-muted
= html_escape(_('Jobs fail if they run longer than the timeout time. Input value is in seconds by default. Human readable input is also accepted, for example %{code_open}1 hour%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'timeout'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'set-a-limit-for-how-long-jobs-can-run'), target: '_blank'
- if can?(current_user, :update_max_artifacts_size, @project)
.form-group
@@ -94,7 +94,7 @@
.input-group-text /
%p.form-text.text-muted
= html_escape(_('The regular expression used to find test coverage output in the job log. For example, use %{regex} for Simplecov (Ruby). Leave blank to disable.')) % { regex: '<code>\(\d+.\d+%\)</code>'.html_safe }
- = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
+ = link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'add-test-coverage-results-to-a-merge-request'), target: '_blank'
= f.submit _('Save changes'), class: "btn gl-button btn-confirm", data: { qa_selector: 'save_general_pipelines_changes_button' }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index ade3d40a8df..70626636ac0 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -41,9 +41,9 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
- = link_to s_('How do I configure runners?'), help_page_path('ci/runners/README'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to s_('How do I configure runners?'), help_page_path('ci/runners/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = render 'projects/runners/index'
+ = render 'projects/runners/settings'
- if Gitlab::CurrentSettings.current_application_settings.keep_latest_artifact?
%section.settings.no-animate#js-artifacts-settings{ class: ('expanded' if expanded) }
@@ -71,13 +71,10 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Trigger a pipeline for a branch or tag by generating a trigger token and using it with an API call. The token impersonates a user's project access and permissions.")
- = link_to _('Learn more.'), help_page_path('ci/triggers/README'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more.'), help_page_path('ci/triggers/index'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'projects/triggers/index'
-- if settings_container_registry_expiration_policy_available?(@project)
- = render 'projects/registry/settings/index'
-
= render_if_exists 'projects/settings/ci_cd/auto_rollback', expanded: expanded
- if can?(current_user, :create_freeze_period, @project)
@@ -98,3 +95,16 @@
.settings-content
= render 'ci/deploy_freeze/index'
+
+- if Feature.enabled?(:ci_scoped_job_token, @project, default_enabled: :yaml)
+ %section.settings.no-animate#js-token-access{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _("Token Access")
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.")
+ = link_to _('Learn more'), help_page_path('api/index', anchor: 'limit-gitlab-cicd-job-token-access'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = render 'ci/token_access/index'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index e2c1a00a587..215448be3d6 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -1,7 +1,6 @@
- @content_class = 'limit-container-width' unless fluid_layout
-- title = Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) ? _('Monitor Settings') : _('Operations Settings')
-- page_title title
-- breadcrumb_title title
+- page_title _('Monitor Settings')
+- breadcrumb_title _('Monitor Settings')
= render 'projects/settings/operations/metrics_dashboard'
= render 'projects/settings/operations/tracing'
@@ -10,4 +9,4 @@
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/grafana_integration'
= render_if_exists 'projects/settings/operations/status_page'
-= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service)
+= render 'projects/settings/operations/prometheus', service: prometheus_integration if Feature.enabled?(:settings_operations_prometheus_service)
diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml
index f878245a48c..16e59757147 100644
--- a/app/views/registrations/experience_levels/show.html.haml
+++ b/app/views/registrations/experience_levels/show.html.haml
@@ -1,4 +1,5 @@
- page_title _('What’s your experience level?')
+- @hide_flash = true
.gl-display-flex.gl-flex-direction-column.gl-align-items-center
= image_tag 'learn-gitlab-avatar.jpg', width: '90'
diff --git a/app/views/registrations/invites/new.html.haml b/app/views/registrations/invites/new.html.haml
deleted file mode 100644
index 0feae9b17e9..00000000000
--- a/app/views/registrations/invites/new.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- page_title _('Join your team')
-- add_page_specific_style 'page_bundles/signup'
-- content_for :page_specific_javascripts do
- = render "layouts/google_tag_manager_head"
-= render "layouts/google_tag_manager_body"
-
-%h2.center.pt-6.pb-3.gl-mb-0
- = _('Join your team')
-%p.gl-text-center= _('Create your own profile to collaborate with your teammates in issues, merge requests, and more.')
-
-.signup-page
- = render 'devise/shared/signup_box',
- url: users_sign_up_invites_path,
- button_text: _('Continue'),
- show_omniauth_providers: social_signin_enabled?,
- omniauth_providers_placement: :top
- = render 'devise/shared/sign_in_link'
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index e85ce1ba6ac..9356b6ad49c 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -1,10 +1,11 @@
+- @html_class = "subscriptions-layout-html"
- page_title _('Your profile')
- add_page_specific_style 'page_bundles/signup'
- gitlab_experience_text = _('To personalize your GitLab experience, we\'d like to know a bit more about you')
.row.gl-flex-grow-1
- .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-p-5
- .edit-profile.login-page.d-flex.flex-column.gl-align-items-center.pt-lg-3
+ .d-flex.gl-flex-direction-column.gl-align-items-center.gl-w-full.gl-px-5.gl-pb-5
+ .edit-profile.login-page.d-flex.flex-column.gl-align-items-center
= render_if_exists "registrations/welcome/progress_bar"
%h2.gl-text-center= html_escape(_('Welcome to GitLab,%{br_tag}%{name}!')) % { name: html_escape(current_user.first_name), br_tag: '<br/>'.html_safe }
- if Gitlab.com?
diff --git a/app/views/root/index.html.haml b/app/views/root/index.html.haml
new file mode 100644
index 00000000000..97dd8e133f5
--- /dev/null
+++ b/app/views/root/index.html.haml
@@ -0,0 +1,10 @@
+- if show_customize_homepage_banner?
+ = content_for :customize_homepage_banner do
+ .gl-display-none.gl-md-display-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" }
+ .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'),
+ preferences_behavior_path: profile_preferences_path(anchor: 'behavior'),
+ callouts_path: user_callouts_path,
+ callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE,
+ track_label: 'home_page' } }
+
+= render template: 'dashboard/projects/index'
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 4ba906dd02f..d5d3cd753f3 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,20 +1,16 @@
- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
+= render_if_exists 'shared/promotions/promote_advanced_search'
+= render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty?
-- if @search_objects.to_a.empty?
- .gl-md-display-flex
- - if %w(issues merge_requests).include?(@scope)
- #js-search-sidebar{ class: search_bar_classes }
- .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
+.results.gl-md-display-flex.gl-mt-3
+ - if %w(issues merge_requests).include?(@scope)
+ #js-search-sidebar{ class: search_bar_classes }
+ .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
+ - if @timeout
+ = render partial: "search/results/timeout"
+ - elsif @search_objects.to_a.empty?
= render partial: "search/results/empty"
- = render_if_exists 'shared/promotions/promote_advanced_search'
-- else
- = render partial: 'search/results_status', locals: { search_service: @search_service }
- = render_if_exists 'shared/promotions/promote_advanced_search'
-
- .results.gl-md-display-flex.gl-mt-3
- - if %w(issues merge_requests).include?(@scope)
- #js-search-sidebar{ class: search_bar_classes }
- .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
+ - else
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index 16d640273b0..fb2825ad15e 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -5,6 +5,7 @@
= sprite_icon('document')
%strong
= search_blob_title(project, path)
+ = copy_file_path_button(path)
- if blob.data
.file-content.code.term{ data: { qa_selector: 'file_text_content' } }
= render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link, highlight_line: blob.highlight_line
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index da0adba88db..551f5c048bc 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -1,14 +1,19 @@
-%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
- %span.gl-display-flex.gl-align-items-center
- %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable)
- = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential?
- = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
- %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
- .gl-text-gray-500.gl-my-3
- = issuable_project_reference(issuable)
- &middot;
- = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
- &middot;
- = sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe
- .description.term.col-sm-10.gl-px-0
- = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
+%div{ class: 'search-result-row gl-display-flex gl-sm-flex-direction-row gl-flex-direction-column gl-align-items-center gl-pb-3! gl-mt-5 gl-mb-0!' }
+ .col-sm-9
+ %span.gl-display-flex.gl-align-items-center
+ %span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable)
+ = sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential?
+ = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ %span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
+ .gl-text-gray-500.gl-my-3
+ = issuable_project_reference(issuable)
+ &middot;
+ = sprintf(s_('created %{issuable_created} by %{author}'), { issuable_created: time_ago_with_tooltip(issuable.created_at, placement: 'bottom'), author: link_to_member(@project, issuable.author, avatar: false) }).html_safe
+ .description.term.gl-px-0
+ = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight)
+ .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right
+ - if Feature.enabled?(:search_sort_issues_by_popularity) && issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0
+ %li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') }
+ = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle")
+ = issuable.upvotes_count
+ %span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe
diff --git a/app/views/search/results/_timeout.html.haml b/app/views/search/results/_timeout.html.haml
new file mode 100644
index 00000000000..740e2bedd54
--- /dev/null
+++ b/app/views/search/results/_timeout.html.haml
@@ -0,0 +1,10 @@
+.gl-display-flex.gl-flex-direction-column.gl-align-items-center
+ %div
+ .svg-content.svg-150
+ = image_tag 'illustrations/search-timeout-md.svg'
+ %div
+ %h4.gl-text-center.gl-font-weight-bold= _('Your search timed out')
+ %p.gl-text-center= _('To resolve this, try to:')
+ %ul
+ %li= html_escape(_('Refine your search criteria (select a %{strong_open}group%{strong_close} and %{strong_open}project%{strong_close} when possible)')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ %li= html_escape(_('Use double quotes for multiple keywords, such as %{code_open}"your search"%{code_close}')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index f788bf53a4c..35a3835a522 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -10,5 +10,5 @@
%div
= _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.')
.gl-alert-actions
- = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-info'
- = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-2'
+ = link_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link btn gl-button btn-confirm'
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link btn gl-button btn-default gl-ml-3'
diff --git a/app/views/shared/_confirm_your_email_alert.html.haml b/app/views/shared/_confirm_your_email_alert.html.haml
new file mode 100644
index 00000000000..b9906a89ce4
--- /dev/null
+++ b/app/views/shared/_confirm_your_email_alert.html.haml
@@ -0,0 +1,7 @@
+.js-vue-alert{ 'v-cloak': true,
+ data: { dismissible: 'true',
+ title: _('Please confirm your email address'),
+ primary_button_text: _('Resend confirmation email'),
+ primary_button_link: new_user_confirmation_path,
+ variant: 'warning'} }
+ = (_("To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select %{strongStart}Resend confirmation email.%{strongEnd}") % { strongStart: '<strong>', strongEnd: '</strong>' }).html_safe
diff --git a/app/views/shared/_global_alert.html.haml b/app/views/shared/_global_alert.html.haml
index bebc72fe428..ea83f5c1656 100644
--- a/app/views/shared/_global_alert.html.haml
+++ b/app/views/shared/_global_alert.html.haml
@@ -2,19 +2,23 @@
- title = local_assigns.fetch(:title, nil)
- variant = local_assigns.fetch(:variant, :info)
+- dismissible = local_assigns.fetch(:dismissible, true)
- alert_class = local_assigns.fetch(:alert_class, nil)
- alert_data = local_assigns.fetch(:alert_data, nil)
- close_button_class = local_assigns.fetch(:close_button_class, nil)
- close_button_data = local_assigns.fetch(:close_button_data, nil)
- icon = icons[variant]
+- alert_root_class = 'gl-alert-layout-limited' if fluid_layout
+- alert_container_class = [container_class, @content_class] unless fluid_layout || local_assigns.fetch(:is_contained, false)
-%div{ role: 'alert', class: ["gl-alert-#{variant}", alert_class], data: alert_data }
- %div{ class: [container_class, @content_class, 'gl-px-0!'] }
- .gl-alert
- = sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
- %button.gl-alert-dismiss.js-close{ type: 'button', aria: { label: _('Close') }, class: close_button_class, data: close_button_data }
+%div{ role: 'alert', class: [alert_root_class, 'gl-alert-max-content', 'gl-alert', "gl-alert-#{variant}", alert_class], data: alert_data }
+ .gl-alert-container{ class: alert_container_class }
+ = sprite_icon(icon, size: 16, css_class: "gl-alert-icon#{' gl-alert-icon-no-title' if title.nil?}")
+ - if dismissible
+ %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ type: 'button', aria: { label: _('Dismiss') }, class: close_button_class, data: close_button_data }
= sprite_icon('close', size: 16)
+ .gl-alert-content{ role: 'alert' }
- if title
- .gl-alert-title
+ %h4.gl-alert-title
= title
= yield
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 7055dc8142a..e96372a29db 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -28,8 +28,7 @@
title: _('Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
%p.validation-error.gl-field-error.field-validation.hide
- = _('Group path is already taken. Suggestions: ')
- %span.gl-path-suggestions
+ = _("Group path is already taken. We've suggested one that is available.")
%p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group URL availability...')
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 65e02341936..f03314563cb 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -8,7 +8,18 @@
= _('Git repository URL')
= f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
+ = render 'shared/global_alert',
+ variant: :warning,
+ alert_class: 'gl-mt-3 js-import-url-warning hide',
+ dismissible: false,
+ close_button_class: 'js-close-2fa-enabled-success-alert' do
+ .gl-alert-body
+ = s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.')
+ .gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning
+ .gl-alert-container
+ = sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-content{ role: 'alert' }
.row
.form-group.col-md-6
= f.label :import_url_user, class: 'label-bold' do
diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml
index 3817ff8a56d..d5f4add2796 100644
--- a/app/views/shared/_new_project_item_select.html.haml
+++ b/app/views/shared/_new_project_item_select.html.haml
@@ -1,5 +1,5 @@
- if any_projects?(@projects)
- .project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-py-3.gl-relative.gl-display-flex.gl-overflow-hidden
+ .project-item-select-holder.btn-group.gl-ml-auto.gl-mr-auto.gl-relative.gl-overflow-hidden{ class: 'gl-display-flex!' }
%a.btn.gl-button.btn-confirm.new-project-item-link.block-truncated.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] }, class: "gl-m-0!" }
= loading_icon(color: 'light')
= project_select_tag :project_path, class: "project-item-select gl-absolute! gl-visibility-hidden", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled]
diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml
index 9110f5a7f31..90612ba623f 100644
--- a/app/views/shared/_project_limit.html.haml
+++ b/app/views/shared/_project_limit.html.haml
@@ -1,8 +1,10 @@
- if cookies[:hide_project_limit_message].blank? && !current_user.hide_project_limit && !current_user.can_create_project? && current_user.projects_limit > 0
- .project-limit-message.gl-alert.gl-alert-warning.gl-display-none.gl-sm-display-block
- = _("You won't be able to create new projects because you have reached your project limit.")
-
- .float-right
- = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link'
- |
- = link_to _('Remind later'), '#', class: 'hide-project-limit-message alert-link'
+ = render 'shared/global_alert',
+ variant: :warning,
+ dismissible: false,
+ alert_class: 'project-limit-message' do
+ .gl-alert-body
+ = _("You won't be able to create new projects because you have reached your project limit.")
+ .gl-alert-actions
+ = link_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message btn gl-button btn-confirm'
+ = link_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link btn gl-button btn-default gl-ml-3'
diff --git a/app/views/shared/_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index d0f1e4d7221..77597124e5c 100644
--- a/app/views/shared/_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,5 +1,5 @@
- if session[:ask_for_usage_stats_consent]
- .ping-consent-message.gl-alert.gl-alert-info
+ .service-ping-consent-message.gl-alert.gl-alert-info
= sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', css_class: 'gl-icon')
@@ -8,7 +8,7 @@
- settings_link = link_to _('your settings'), metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link'
= s_('To help improve GitLab, we would like to periodically %{docs_link}. This can be changed at any time in %{settings_link}.').html_safe % { docs_link: docs_link, settings_link: settings_link }
.gl-alert-actions.gl-mt-3
- - send_usage_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 })
+ - send_service_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 })
- not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 })
- = link_to _("Send usage data"), send_usage_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-ping-enabled': true, class: 'js-usage-consent-action alert-link btn gl-button btn-info'
- = link_to _("Don't send usage data"), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-ping-enabled': false, class: 'js-usage-consent-action alert-link btn gl-button btn-default gl-ml-2'
+ = link_to _("Send service data"), send_service_data_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link btn gl-button btn-info'
+ = link_to _("Don't send service data"), not_now_path, 'data-url' => admin_application_settings_path, method: :put, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link btn gl-button btn-default gl-ml-2'
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index a5a411db8a0..b3d6c4c327b 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,7 +1,5 @@
%a.toggle-sidebar-button.js-toggle-sidebar.qa-toggle-sidebar.rspec-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
= sprite_icon('chevron-double-lg-left', css_class: 'icon-chevron-double-lg-left')
- - if sidebar_refactor_disabled?
- = sprite_icon('chevron-double-lg-right', css_class: 'icon-chevron-double-lg-right')
%span.collapse-text.gl-ml-3= _("Collapse sidebar")
= button_tag class: 'close-nav-button', type: 'button' do
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index 88c24a27497..6435475a9a3 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -1,5 +1,9 @@
- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type })
- prefix = local_assigns.fetch(:prefix, :personal_access_token)
+- help_path = local_assigns.fetch(:help_path)
+- project = local_assigns.fetch(:project, false)
+- access_levels = local_assigns.fetch(:access_levels, false)
+- default_access_level = local_assigns.fetch(:default_access_level, false)
%h5.gl-mt-0
= title
@@ -11,13 +15,16 @@
= form_errors(token)
.row
- .form-group.col-md-6
- = f.label :name, _('Name'), class: 'label-bold'
- = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }
+ .form-group.col
+ .row
+ = f.label :name, _('Token name'), class: 'label-bold col-md-12'
+ .col-md-6
+ = f.text_field :name, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'access_token_name_field' }, :'aria-describedby' => 'access_token_help_text'
+ %span.form-text.text-muted.col-md-12#access_token_help_text= _('For example, the application using the token or the purpose of the token.')
.row
.form-group.col-md-6
- = f.label :expires_at, _('Expires at'), class: 'label-bold'
+ = f.label :expires_at, _('Expiration date'), class: 'label-bold'
.input-icon-wrapper
= render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
@@ -25,8 +32,20 @@
.js-access-tokens-expires-at
= f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' }
+ - if project
+ .row
+ .form-group.col-md-6
+ = label_tag :access_level, _("Select a role"), class: "label-bold"
+ .select-wrapper
+ = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control", data: { qa_selector: 'access_token_access_level' }
+ = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200")
+
.form-group
- = f.label :scopes, _('Scopes'), class: 'label-bold'
+ %b{ :'aria-describedby' => 'select_scope_help_text' }
+ = s_('Tokens|Select scopes')
+ %p.text-secondary#select_scope_help_text
+ = s_('Tokens|Scopes set the permission levels granted to the token.')
+ = link_to "Learn more.", help_path, target: '_blank'
= render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
- if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user)
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 9c59d5ae1fa..1f08bff9858 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -1,10 +1,15 @@
- no_active_tokens_message = local_assigns.fetch(:no_active_tokens_message, _('This user has no active %{type}.') % { type: type_plural })
- impersonation = local_assigns.fetch(:impersonation, false)
+- project = local_assigns.fetch(:project, false)
+- personal = !impersonation && !project
%hr
%h5
= _('Active %{type} (%{token_length})') % { type: type_plural, token_length: active_tokens.length }
+- if personal && !personal_access_token_expiration_enforced?
+ %p.profile-settings-content
+ = _("Personal access tokens are not revoked upon expiration.")
- if impersonation
%p.profile-settings-content
= _("To see all the user's personal access tokens you must impersonate them first.")
@@ -14,18 +19,21 @@
%table.table.active-tokens
%thead
%tr
- %th= _('Name')
+ %th= _('Token name')
+ %th= _('Scopes')
%th= s_('AccessTokens|Created')
%th
= _('Last Used')
= link_to sprite_icon('question-o'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'view-the-last-time-a-token-was-used'), target: '_blank'
%th= _('Expires')
- %th= _('Scopes')
+ - if project
+ %th= _('Role')
%th
%tbody
- active_tokens.each do |token|
%tr
%td= token.name
+ %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
%td= token.created_at.to_date.to_s(:medium)
%td
- if token.last_used_at?
@@ -42,8 +50,9 @@
= _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) }
- else
%span.token-never-expires-label= _('Never')
- %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
- %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'gl-button btn btn-danger btn-sm float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
+ - if project
+ %td= project.project_member(token.user).human_access
+ %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: "gl-button btn btn-danger btn-sm float-right qa-revoke-button #{'btn-danger-secondary' unless token.expires?}", data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } }
- else
.settings-message.text-center
= no_active_tokens_message
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index c1a50cfe718..9ccd5655fb0 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -2,6 +2,7 @@
- group = local_assigns.fetch(:group, false)
- @no_breadcrumb_container = true
- @no_container = true
+- @content_wrapper_class = "#{@content_wrapper_class} gl-relative"
- @content_class = "issue-boards-content js-focus-mode-board"
- if board.to_type == "EpicBoard"
- breadcrumb_title _("Epic Boards")
@@ -9,6 +10,9 @@
- breadcrumb_title _("Issue Boards")
= render 'shared/alerts/positioning_disabled'
+= content_for :after_content do
+ #js-right-sidebar-portal
+
- page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards'
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 452e54f9cd4..bf2514f8b0d 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -13,7 +13,7 @@
= form.label :key, class: 'col-form-label col-sm-2'
.col-sm-10
%p.light
- - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe
+ - link_start = "<a href='#{help_page_path('ssh/index')}' target='_blank' rel='noreferrer noopener'>".html_safe
- link_end = '</a>'
= _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
= form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' }
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 0c671b4a1c0..8da48a7936a 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -9,7 +9,7 @@
.form-group.row
%p.light.gl-mb-0
= _('Paste a public key here.')
- = link_to _('How do I generate it?'), help_page_path("ssh/README")
+ = link_to _('How do I generate it?'), help_page_path("ssh/index")
= f.fields_for :deploy_keys_projects do |deploy_keys_project_form|
.form-group.row
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index 976776ccc62..5d351bd11fd 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -38,7 +38,7 @@
- if packages_registry_enabled?(group_or_project)
%fieldset.form-group.form-check
- = f.check_box :read_package_registry, class: 'form-check-input'
+ = f.check_box :read_package_registry, class: 'form-check-input', data: { qa_selector: 'deploy_token_read_package_registry_checkbox' }
= f.label :read_package_registry, 'read_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows read access to the package registry.')
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 13d9d71d58e..9842457a2eb 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -47,7 +47,7 @@
= link_to _('New issue'), button_path, class: 'gl-button btn btn-confirm', id: 'new_issue_link'
- if show_import_button
- .js-csv-import-export-buttons{ data: { show_import_button: show_import_button.to_s, issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
+ .js-csv-import-export-buttons{ data: { show_import_button: 'true', issuable_type: issuable_type, import_csv_issues_path: import_csv_namespace_project_issues_path, can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), container_class: 'gl-display-inline-flex gl-vertical-align-middle', show_label: 'true' } }
%hr
%p.gl-text-center.gl-mb-0
%strong
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index bbbb728d048..3a526a9f306 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -6,21 +6,13 @@
= form_tag [:bulk_update, @project, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.filter-item.inline.update-issues-btn.float-left
- = button_tag _('Update all'), class: "gl-button btn update-selected-issues btn-confirm", disabled: true
+ = button_tag _('Update all'), class: "gl-button btn js-update-selected-issues btn-confirm", disabled: true
= button_tag _('Cancel'), class: "gl-button btn btn-default js-bulk-update-menu-hide float-right"
- if params[:state] != 'merged'
.block
.title
= _('Status')
- .filter-item
- = dropdown_tag(_("Select status"), options: { toggle_class: "js-issue-status", title: _("Change status"), dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: _("Status") } } ) do
- %ul
- %li
- %a{ href: "#", data: { id: "reopen" } }
- = _('Open')
- %li
- %a{ href: "#", data: { id: "close" } }
- = _('Closed')
+ .js-issue-status
.block
.title
= _('Assignee')
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index e79719d41b0..6aa80e6808d 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -6,11 +6,16 @@
= form_errors(issuable)
- if @conflict
- .gl-alert.gl-alert-danger.gl-mb-5
- Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
- Please check out
- = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project, issuable]), target: "_blank", rel: 'noopener noreferrer'
- and make sure your changes will not unintentionally remove theirs
+ = render 'shared/global_alert',
+ variant: :danger,
+ dismissible: false,
+ is_contained: true,
+ alert_class: 'gl-mb-5' do
+ .gl-alert-body
+ Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
+ Please check out
+ = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project, issuable]), target: "_blank", rel: 'noopener noreferrer'
+ and make sure your changes will not unintentionally remove theirs
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
diff --git a/app/views/shared/issuable/_invite_members_trigger.html.haml b/app/views/shared/issuable/_invite_members_trigger.html.haml
deleted file mode 100644
index 5dd6ec0addf..00000000000
--- a/app/views/shared/issuable/_invite_members_trigger.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- return unless can_import_members?
-
-.js-invite-members-modal{ data: { id: project.id,
- name: project.name,
- is_project: 'true',
- access_levels: ProjectMember.access_level_roles.to_json,
- default_access_level: Gitlab::Access::GUEST,
- help_link: help_page_url('user/permissions') } }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index c03697a4076..737a0ff8c5b 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -25,6 +25,8 @@
= check_box_tag checkbox_id, nil, false, class: "check-all-issues left"
- if is_epic_board
#js-board-filtered-search{ data: { full_path: @group&.full_path } }
+ - elsif Feature.enabled?(:issue_boards_filtered_search, board&.resource_parent) && board
+ #js-issue-board-filtered-search
- else
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 416c788603a..c76aa176696 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -10,19 +10,13 @@
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in }, issuable_type: issuable_type }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite', 'aria-label': issuable_type }
.issuable-sidebar
- .block.issuable-sidebar-header
- - if signed_in
- %span.issuable-header-text.hide-collapsed.float-left
- = _('To Do')
+ .issuable-sidebar-header.gl-py-3
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- if signed_in
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
+ .js-issuable-todo{ data: { project_path: issuable_sidebar[:project_full_path], iid: issuable_sidebar[:iid], id: issuable_sidebar[:id] } }
= form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- - if signed_in
- .block.todo.hide-expanded
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
.block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
@@ -34,34 +28,11 @@
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_milestone]
- - milestone = issuable_sidebar[:milestone] || {}
.block.milestone{ :class => ("gl-border-b-0!" if issuable_sidebar[:supports_iterations]), data: { qa_selector: 'milestone_block' } }
- .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
- = sprite_icon('clock')
- %span.milestone-title.collapse-truncated-title
- - if milestone.present?
- = milestone[:title]
- - else
- = _('None')
- .hide-collapsed.gl-line-height-20.gl-mb-2.gl-text-gray-900{ data: { testid: "milestone_title" } }
- = _('Milestone')
- = loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
- .value.hide-collapsed
- - if milestone.present?
- - milestone_title = milestone[:expired] ? _("%{milestone_name} (Past due)").html_safe % { milestone_name: milestone[:title] } : milestone[:title]
- = link_to milestone_title, milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
- - else
- %span.no-value
- = _('None')
-
- .selectbox.hide-collapsed
- = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
- = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
+ .js-milestone-select{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- if @project.group.present? && issuable_sidebar[:supports_iterations]
- .block{ class: 'gl-pt-0!' }
+ .block{ class: 'gl-pt-0!', data: { qa_selector: 'iteration_container' } }
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_time_tracking]
@@ -75,13 +46,13 @@
.js-sidebar-labels{ data: sidebar_labels_data(issuable_sidebar, @project) }
- = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
+ = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar, can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid]
- if issuable_sidebar[:supports_severity]
#js-severity
- if issuable_sidebar.dig(:features_available, :health_status)
- .js-sidebar-status-entry-point
+ .js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
- if issuable_sidebar.has_key?(:confidential)
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml
deleted file mode 100644
index a867421298b..00000000000
--- a/app/views/shared/issuable/_sidebar_todo.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- is_collapsed = local_assigns.fetch(:is_collapsed, false)
-- has_todo = !!issuable_sidebar.dig(:current_user, :todo, :id)
-
-- todo_button_data = issuable_todo_button_data(issuable_sidebar, is_collapsed)
-- button_title = has_todo ? todo_button_data[:mark_text] : todo_button_data[:todo_text]
-- button_icon = has_todo ? todo_button_data[:mark_icon] : todo_button_data[:todo_icon]
-
-%button.issuable-todo-btn.js-issuable-todo{ type: 'button',
- class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'gl-button btn btn-default issuable-header-btn float-right'),
- title: button_title,
- 'aria-label' => button_title,
- data: todo_button_data }
- %span.issuable-todo-inner.js-issuable-todo-inner<
- = is_collapsed ? button_icon : button_title
- = loading_icon(css_class: is_collapsed ? '' : 'gl-ml-3')
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 9e3caf62d77..caf271e9ee9 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -1,6 +1,7 @@
- sort_value = @sort
- sort_title = issuable_sort_option_title(sort_value)
- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
+- viewing_merge_requests = controller.controller_name == 'merge_requests'
.dropdown.inline.gl-ml-3.issue-sort-dropdown
.btn-group{ role: 'group' }
@@ -17,6 +18,7 @@
= sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues
= sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title)
= sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title)
+ = sortable_item(sort_title_merged_date, page_filter_path(sort: sort_value_merged_date), sort_title) if viewing_merge_requests
= sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues
= render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index ceedb5e5c59..0bf002fbbc5 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -20,6 +20,9 @@
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
+ - if can?(current_user, :admin_feature_flags_issue_links, @project)
+ = render_if_exists 'projects/issues/related_feature_flags'
+
- if can?(current_user, :download_code, @project)
- add_page_startup_api_call related_branches_path
#related-branches{ data: { url: related_branches_path } }
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
deleted file mode 100644
index 2aac3a94c49..00000000000
--- a/app/views/shared/members/_group.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-- group_link = local_assigns[:group_link]
-- group = group_link.shared_with_group
-- can_admin_member = local_assigns[:can_admin_member]
-- group_link_path = local_assigns[:group_link_path]
-- dom_id = "group_member_#{group_link.id}"
-
--# Note this is just for groups. For individual members please see shared/members/_member
-
-%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } }
- %span.list-item-name.mb-2.m-md-0
- = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '')
- .user-info
- = link_to group.full_name, group_path(group), class: 'member'
- .cgray
- Given access #{time_ago_with_tooltip(group_link.created_at)}
- %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) }
- &middot;
- %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) }
- - if group_link.expires?
- = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
- .controls.member-controls.align-items-center
- = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
- = hidden_field_tag "group_link[group_access]", group_link.group_access
- .member-form-control.dropdown.mr-sm-2.d-sm-inline-block
- %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
- disabled: !can_admin_member,
- data: { toggle: "dropdown", field_name: "group_link[group_access]" } }
- %span.dropdown-toggle-text
- = group_link.human_access
- = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable
- = dropdown_title(_("Change role"))
- .dropdown-content
- %ul
- - Gitlab::Access.options_with_owner.each do |role, role_id|
- %li
- = link_to role, '#',
- class: ("is-active" if group_link.group_access == role_id),
- data: { id: role_id, el_id: dom_id }
- .clearable-input.member-form-control.d-sm-inline-block
- = text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member
- = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- - if can_admin_member
- = link_to group_link_path,
- method: :delete,
- data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, qa_selector: 'delete_group_access_link' },
- class: 'gl-button btn btn-danger m-0 ml-sm-2 align-self-center' do
- %span.d-block.d-sm-none
- = _("Delete")
- = sprite_icon('remove', css_class: 'd-none d-sm-block')
diff --git a/app/views/shared/members/_manage_access_button.html.haml b/app/views/shared/members/_manage_access_button.html.haml
new file mode 100644
index 00000000000..13509a7480a
--- /dev/null
+++ b/app/views/shared/members/_manage_access_button.html.haml
@@ -0,0 +1,7 @@
+- path = local_assigns.fetch(:path, nil)
+
+.gl-float-right
+ = link_to path, class: 'btn btn-default btn-sm gl-button' do
+ = sprite_icon('pencil-square', css_class: 'gl-icon gl-button-icon')
+ %span.gl-button-text
+ = _('Manage access')
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 8f334be0427..ba0e5e492f4 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -1,5 +1,4 @@
- show_roles = local_assigns.fetch(:show_roles, true)
-- show_controls = local_assigns.fetch(:show_controls, true)
- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
- member = local_assigns.fetch(:member)
- current_user_is_group_owner = local_assigns.fetch(:current_user_is_group_owner, false)
@@ -7,11 +6,10 @@
- group = local_assigns.fetch(:group)
- user = local_assigns.fetch(:user, member.user)
- source = member.source
-- override = member.try(:override)
-# Note this is just for individual members. For groups please see shared/members/_group
-%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
+%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } }
%span.list-item-name.mb-2.m-md-0
- if user
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
@@ -62,70 +60,4 @@
- if show_roles
.controls.member-controls.align-items-center
= render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override?
- - if show_controls && member.source == membership_source
-
- - if member.can_resend_invite?
- = link_to sprite_icon('paper-airplane'), polymorphic_path([:resend_invite, member]),
- method: :post,
- class: 'gl-button btn btn-default align-self-center mr-sm-2',
- title: _('Resend invite')
-
- - if user != current_user && member.can_update?
- = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
- = f.hidden_field :access_level
- .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
- %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
- disabled: member.can_override? && !override,
- data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]", qa_selector: "access_level_dropdown" } }
- %span.dropdown-toggle-text
- = member.human_access
- = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable
- = dropdown_title(_("Change role"))
- .dropdown-content
- %ul
- - member.valid_level_roles.each do |role, role_id|
- %li
- = link_to role, '#',
- class: ("is-active" if member.access_level == role_id),
- data: { id: role_id, el_id: dom_id(member), qa_selector: "#{role.downcase}_access_level_link" }
- = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option',
- group: group,
- member: member,
- can_override: member.can_override?
- .clearable-input.member-form-control{ class: [("d-sm-inline-block" unless force_mobile_view)] }
- = f.text_field :expires_at,
- disabled: member.can_override? && !override,
- class: 'form-control js-access-expiration-date js-member-update-control',
- placeholder: _('Expiration date'),
- id: "member_expires_at_#{member.id}",
- data: { el_id: dom_id(member) }
- = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200')
- - else
- %span.member-access-text.user-access-role= member.human_access
-
- - if member.can_approve?
- = link_to polymorphic_path([:approve_access_request, member]),
- method: :post,
- class: "btn btn-confirm btn-icon gl-button align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
- title: _('Grant access') do
- %span{ class: ('d-block d-sm-none' unless force_mobile_view) }
- = _('Grant access')
- - unless force_mobile_view
- = sprite_icon('check', css_class: 'd-none d-sm-block')
-
- - if member.can_remove?
- - if current_user == user
- = link_to polymorphic_path([:leave, member.source, :members]), method: :delete, data: { confirm: leave_confirmation_message(member.source) }, class: "btn gl-button btn-svg btn-danger align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" do
- = sprite_icon('leave', css_class: 'gl-icon')
- = _('Leave')
- - else
- %button{ data: { member_path: member_path(member.member), member_type: member.type, message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' },
- class: "js-remove-member-button btn gl-button btn-danger align-self-center m-0 #{'ml-sm-2 btn-icon' unless force_mobile_view}",
- title: remove_member_title(member) }
- %span{ class: ('d-block d-sm-none' unless force_mobile_view) }
- = _("Delete")
- - unless force_mobile_view
- = sprite_icon('remove', css_class: 'd-none d-sm-block gl-icon')
- - else
- %span.member-access-text.user-access-role= member.human_access
+ %span.member-access-text.user-access-role= member.human_access
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 3aa43ed1922..8b0a85656dc 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -1,20 +1,19 @@
- membership_source = local_assigns.fetch(:membership_source)
- requesters = local_assigns.fetch(:requesters)
-- force_mobile_view = local_assigns.fetch(:force_mobile_view, false)
- group = local_assigns.fetch(:group)
- current_user_is_group_owner = group && group.has_owner?(current_user)
- return if requesters.empty?
-.card.gl-mt-3{ class: ('card-mobile' if force_mobile_view ) }
+.card.gl-mt-3{ data: { testid: 'access-requests' } }
.card-header
= _("Users requesting access to")
%strong= membership_source.name
%span.badge.badge-pill= requesters.size
+ = render 'shared/members/manage_access_button', path: membership_source.is_a?(Project) ? project_project_members_path(@project, tab: 'access_requests') : group_group_members_path(@group, tab: 'access_requests')
%ul.content-list.members-list
= render partial: 'shared/members/member',
collection: requesters, as: :member,
locals: { membership_source: membership_source,
group: group,
- force_mobile_view: force_mobile_view,
current_user_is_group_owner: current_user_is_group_owner }
diff --git a/app/views/shared/members/_search_field.html.haml b/app/views/shared/members/_search_field.html.haml
deleted file mode 100644
index b1e3134f7aa..00000000000
--- a/app/views/shared/members/_search_field.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-- name = local_assigns.fetch(:name, :search)
-
-.search-control-wrap.gl-relative
- = search_field_tag name, params[name], { placeholder: _('Search'), class: 'form-control', spellcheck: false }
- %button.user-search-btn.border-left.gl-display-flex.gl-align-items-center.gl-justify-content-center{ type: 'submit', 'aria': { label: _('Submit search') }, data: { testid: 'user-search-submit' } }
- = sprite_icon('search')
diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml
deleted file mode 100644
index 682e3a0433b..00000000000
--- a/app/views/shared/members/_sort_dropdown.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.dropdown.inline.qa-user-sort-dropdown{ data: { testid: 'user-sort-dropdown' } }
- = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown', testid: 'dropdown-toggle' })
- %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
- %li.dropdown-header
- = _("Sort by")
- - member_sort_options_hash.each do |value, title|
- %li
- = link_to filter_group_project_member_path(sort: value), class: ("is-active" if @sort == value) do
- = title
- %li.divider
- %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
- = link_to filter_group_project_member_path(with_inherited_permissions: nil), class: ("is-active" unless params[:with_inherited_permissions].present?) do
- = _("Show all members")
- %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
- = link_to filter_group_project_member_path(with_inherited_permissions: 'exclude'), class: ("is-active" if params[:with_inherited_permissions] == 'exclude') do
- = _("Show only direct members")
- %li{ data: { testid: 'filter-members-with-inherited-permissions' } }
- = link_to filter_group_project_member_path(with_inherited_permissions: 'only'), class: ("is-active" if params[:with_inherited_permissions] == 'only') do
- = _("Show only inherited members")
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index e0664c1feba..7a41e381a96 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -1,13 +1,11 @@
-.col-md-6
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :start_date, _('Start Date')
- .col-sm-10
- = f.text_field :start_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
- %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date')
- .form-group.row
- .col-form-label.col-sm-2
- = f.label :due_date, _('Due Date')
- .col-sm-10
- = f.text_field :due_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
- %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date')
+.form-group.row
+ .col-form-label.col-sm-2
+ = f.label :start_date, _('Start Date')
+ .col-sm-4
+ = f.text_field :start_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off'
+ %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date')
+ .col-form-label.col-sm-2
+ = f.label :due_date, _('Due Date')
+ .col-sm-4
+ = f.text_field :due_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off'
+ %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date')
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 184904dd7ab..12380d4c34e 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -25,3 +25,5 @@
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }),
class: 'has-tooltip', title: _("Assigned to %{assignee_name}") % { assignee_name: assignee.name }, data: { container: 'body' } do
- image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '')
+
+ = render_if_exists "shared/milestones/issuable_weight", issuable: issuable
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index 9147e1c50e3..460ddd0897c 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -4,11 +4,15 @@
.card
.card-header{ class: panel_class }
- .title
- = title
- - if show_counter
- .counter
- = number_with_delimiter(issuables.length)
+ .header.gl-mb-2
+ .title
+ = title
+ .issuable-count-weight.gl-ml-3
+ - if show_counter
+ %span.counter
+ = sprite_icon('issues', css_class: 'gl-vertical-align-text-bottom')
+ = number_with_delimiter(issuables.length)
+ = render_if_exists "shared/milestones/issuables_weight", issuables: issuables
- class_prefix = dom_class(issuables).pluralize
%ul{ class: "content-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" }
diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml
new file mode 100644
index 00000000000..1c25fae747e
--- /dev/null
+++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml
@@ -0,0 +1,10 @@
+- milestone = local_assigns[:milestone]
+
+- if milestone.complete? && milestone.active?
+ = render 'shared/global_alert',
+ variant: :success,
+ is_contained: true,
+ alert_data: { testid: 'all-issues-closed-alert' },
+ dismissible: false do
+ .gl-alert-body
+ = yield
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index c37fdf0c98f..2709a39c475 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -5,11 +5,8 @@
= render 'shared/milestones/header', milestone: milestone
= render 'shared/milestones/description', milestone: milestone
-
-- if milestone.complete? && milestone.active?
- .gl-alert.gl-alert-success.gl-mt-3
- %span
- = _('All issues for this milestone are closed.')
- = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.')
+= render 'shared/milestones/milestone_complete_alert', milestone: milestone do
+ = _('All issues for this milestone are closed.')
+ = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.')
= render_if_exists 'shared/milestones/burndown', milestone: milestone, project: @project
diff --git a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
index ab4d8816ec9..cfa87351689 100644
--- a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
+++ b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml
@@ -1,9 +1,10 @@
- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
- form = local_assigns.fetch(:form, nil)
- setting_locked = local_assigns.fetch(:setting_locked, false)
- help_text = local_assigns.fetch(:help_text, s_('CascadingSettings|Subgroups cannot change this setting.'))
-- return unless attribute && group && form && cascading_namespace_settings_enabled?
+- return unless attribute && group && form
- return if setting_locked
- lock_attribute = "lock_#{attribute}"
diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml
index d27b3641637..83d602aba21 100644
--- a/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml
+++ b/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml
@@ -1,10 +1,11 @@
- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil)
- form = local_assigns.fetch(:form, nil)
- setting_locked = local_assigns.fetch(:setting_locked, false)
- help_text = local_assigns.fetch(:help_text, nil)
-- return unless attribute && form && settings_path_helper
+- return unless attribute && group && form && settings_path_helper
= form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do
= render 'shared/namespaces/cascading_settings/setting_label_container' do
diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml
index 4a2ec9f30fd..66c760b466c 100644
--- a/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml
+++ b/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml
@@ -1,9 +1,10 @@
- attribute = local_assigns.fetch(:attribute, nil)
+- group = local_assigns.fetch(:group, nil)
- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil)
- setting_locked = local_assigns.fetch(:setting_locked, false)
- help_text = local_assigns.fetch(:help_text, nil)
-- return unless attribute && settings_path_helper
+- return unless attribute && group && settings_path_helper
%legend.h5.gl-border-none.gl-m-0
= render 'shared/namespaces/cascading_settings/setting_label_container' do
diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml
index cbee0e0429c..1a7089fb570 100644
--- a/app/views/shared/nav/_scope_menu.html.haml
+++ b/app/views/shared/nav/_scope_menu.html.haml
@@ -1,6 +1,6 @@
-- if sidebar_refactor_enabled?
- = nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do
- = render 'shared/nav/scope_menu_body', scope_menu: scope_menu
-- else
- .context-header
- = render 'shared/nav/scope_menu_body', scope_menu: scope_menu
+= nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do
+ = link_to scope_menu.link, **scope_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: scope_qa_menu_item(scope_menu.container) } do
+ %span{ class: scope_avatar_classes(scope_menu.container) }
+ = source_icon(scope_menu.container, alt: scope_menu.title, class: ['avatar', 'avatar-tile', 's32'], width: 32, height: 32)
+ %span.sidebar-context-title
+ = scope_menu.title
diff --git a/app/views/shared/nav/_scope_menu_body.html.haml b/app/views/shared/nav/_scope_menu_body.html.haml
deleted file mode 100644
index a94c681e2d3..00000000000
--- a/app/views/shared/nav/_scope_menu_body.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- avatar_size = sidebar_refactor_disabled? ? 40 : 32
-- avatar_size_class = sidebar_refactor_disabled? ? 's40' : 's32'
-
-= link_to scope_menu.link, **scope_menu.container_html_options, data: { qa_selector: 'project_scope_link' } do
- %span{ class: ['avatar-container', 'rect-avatar', 'project-avatar', avatar_size_class] }
- = source_icon(scope_menu.container, alt: scope_menu.title, class: ['avatar', 'avatar-tile', avatar_size_class], width: avatar_size, height: avatar_size)
- %span.sidebar-context-title
- = scope_menu.title
diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml
index 54c3b8a281d..915352996d9 100644
--- a/app/views/shared/nav/_sidebar.html.haml
+++ b/app/views/shared/nav/_sidebar.html.haml
@@ -1,15 +1,14 @@
%aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label }
.nav-sidebar-inner-scroll
- - if sidebar.scope_menu && sidebar_refactor_disabled?
- = render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu
- - elsif sidebar.render_raw_scope_menu_partial
- = render sidebar.render_raw_scope_menu_partial
-
- %ul.sidebar-top-level-items.qa-project-sidebar
- - if sidebar.scope_menu && sidebar_refactor_enabled?
+ %ul.sidebar-top-level-items{ data: { qa_selector: sidebar_qa_selector(sidebar.container) } }
+ - if sidebar.render_raw_scope_menu_partial
+ = render sidebar.render_raw_scope_menu_partial
+ - elsif sidebar.scope_menu
= render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu
+
- if sidebar.renderable_menus.any?
= render partial: 'shared/nav/sidebar_menu', collection: sidebar.renderable_menus
+
- if sidebar.render_raw_menus_partial
= render sidebar.render_raw_menus_partial
diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml
index b80bd515a32..9a04139d2f2 100644
--- a/app/views/shared/nav/_sidebar_menu.html.haml
+++ b/app/views/shared/nav/_sidebar_menu.html.haml
@@ -15,12 +15,12 @@
%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) }
= nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do
- - if sidebar_refactor_disabled?
- = link_to sidebar_menu.link, class: "'has-sub-items' if sidebar_menu.has_renderable_items?", **sidebar_menu.collapsed_container_html_options do
- = render 'shared/nav/sidebar_menu_collapsed', sidebar_menu: sidebar_menu
- - else
- %span.fly-out-top-item-container
- = render 'shared/nav/sidebar_menu_collapsed', sidebar_menu: sidebar_menu
+ %span.fly-out-top-item-container
+ %strong.fly-out-top-item-name
+ = sidebar_menu.title
+ - if sidebar_menu.has_pill?
+ %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
+ = number_with_delimiter(sidebar_menu.pill_count)
- if sidebar_menu.has_renderable_items?
%li.divider.fly-out-top-item
diff --git a/app/views/shared/nav/_sidebar_menu_collapsed.html.haml b/app/views/shared/nav/_sidebar_menu_collapsed.html.haml
deleted file mode 100644
index 78567a991df..00000000000
--- a/app/views/shared/nav/_sidebar_menu_collapsed.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-%strong.fly-out-top-item-name
- = sidebar_menu.title
-- if sidebar_menu.has_pill?
- %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
- = number_with_delimiter(sidebar_menu.pill_count)
diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml
index f698e1a301b..b5abd00b8fd 100644
--- a/app/views/shared/snippets/_embed.html.haml
+++ b/app/views/shared/snippets/_embed.html.haml
@@ -4,12 +4,12 @@
= external_snippet_icon('doc-text')
%strong.file-title-name
- %a.gitlab-embedded-snippets-title{ href: url_for(only_path: false, overwrite_params: nil) }
+ %a.gitlab-embedded-snippets-title{ href: url_for(only_path: false, overwrite_params: nil), target: '_blank', rel: 'noopener noreferrer' }
= blob.name
%small
= number_to_human_size(blob.size)
- %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
+ %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'View on GitLab', target: '_blank', rel: 'noopener noreferrer' }
%img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" }
.file-actions.d-none.d-sm-block
diff --git a/app/views/shared/wikis/edit.html.haml b/app/views/shared/wikis/edit.html.haml
index 729646c2731..15710f0df49 100644
--- a/app/views/shared/wikis/edit.html.haml
+++ b/app/views/shared/wikis/edit.html.haml
@@ -18,7 +18,7 @@
.nav-controls.pb-md-3.pb-lg-0
- if @page.persisted?
- - if can?(current_user, :admin_wiki, @wiki.container)
+ - if can?(current_user, :create_wiki, @wiki.container)
#delete-wiki-modal-wrapper{ data: { delete_wiki_url: wiki_page_path(@wiki, @page), page_title: @page.human_title } }
= render 'shared/wikis/form', uploads_path: wiki_attachment_upload_url
diff --git a/app/views/users/unsubscribes/show.html.haml b/app/views/users/unsubscribes/show.html.haml
new file mode 100644
index 00000000000..8b3dc69f3a7
--- /dev/null
+++ b/app/views/users/unsubscribes/show.html.haml
@@ -0,0 +1,11 @@
+- page_title _("Unsubscribe"), _("Admin Notifications")
+%h3.page-title Unsubscribe from Admin notifications
+
+%hr
+= form_tag unsubscribe_path(Base64.urlsafe_encode64(@email)) do
+ %p
+ Yes, I want to unsubscribe
+ %strong= @email
+ from any further admin emails.
+ .form-actions
+ = submit_tag 'Unsubscribe', class: 'gl-button btn btn-confirm'
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 31c590183d1..8d08beb56aa 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -247,6 +247,15 @@
:idempotent: true
:tags:
- :exclude_from_kubernetes
+- :name: cronjob:database_partition_management
+ :worker_name: Database::PartitionManagementWorker
+ :feature_category: :database
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:environments_auto_stop_cron
:worker_name: Environments::AutoStopCronWorker
:feature_category: :continuous_delivery
@@ -265,9 +274,9 @@
:weight: 1
:idempotent:
:tags: []
-- :name: cronjob:gitlab_usage_ping
- :worker_name: GitlabUsagePingWorker
- :feature_category: :usage_ping
+- :name: cronjob:gitlab_service_ping
+ :worker_name: GitlabServicePingWorker
+ :feature_category: :service_ping
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1078,6 +1087,15 @@
:weight: 2
:idempotent: true
:tags: []
+- :name: jira_connect:jira_connect_forward_event
+ :worker_name: JiraConnect::ForwardEventWorker
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: jira_connect:jira_connect_sync_branch
:worker_name: JiraConnect::SyncBranchWorker
:feature_category: :integrations
@@ -1085,7 +1103,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags: []
- :name: jira_connect:jira_connect_sync_builds
:worker_name: JiraConnect::SyncBuildsWorker
@@ -1094,7 +1112,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags:
- :exclude_from_kubernetes
- :name: jira_connect:jira_connect_sync_deployments
@@ -1104,7 +1122,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags:
- :exclude_from_kubernetes
- :name: jira_connect:jira_connect_sync_feature_flags
@@ -1114,7 +1132,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags:
- :exclude_from_kubernetes
- :name: jira_connect:jira_connect_sync_merge_request
@@ -1124,7 +1142,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags: []
- :name: jira_connect:jira_connect_sync_project
:worker_name: JiraConnect::SyncProjectWorker
@@ -1133,7 +1151,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent: true
+ :idempotent:
:tags:
- :exclude_from_kubernetes
- :name: jira_importer:jira_import_advance_stage
@@ -1309,6 +1327,15 @@
:idempotent: true
:tags:
- :exclude_from_kubernetes
+- :name: package_repositories:packages_helm_extraction
+ :worker_name: Packages::Helm::ExtractionWorker
+ :feature_category: :package_registry
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_repositories:packages_maven_metadata_sync
:worker_name: Packages::Maven::Metadata::SyncWorker
:feature_category: :package_registry
@@ -1347,6 +1374,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: pipeline_background:ci_archive_trace
+ :worker_name: Ci::ArchiveTraceWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: pipeline_background:ci_build_trace_chunk_flush
:worker_name: Ci::BuildTraceChunkFlushWorker
:feature_category: :continuous_integration
@@ -1567,6 +1603,15 @@
:weight: 5
:idempotent:
:tags: []
+- :name: pipeline_processing:ci_build_finished
+ :worker_name: Ci::BuildFinishedWorker
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :cpu
+ :weight: 5
+ :idempotent:
+ :tags: []
- :name: pipeline_processing:ci_build_prepare
:worker_name: Ci::BuildPrepareWorker
:feature_category: :continuous_integration
@@ -1601,7 +1646,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 5
- :idempotent:
+ :idempotent: true
:tags: []
- :name: pipeline_processing:pipeline_process
:worker_name: PipelineProcessWorker
diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb
index 629526ec17c..ecde05f94dc 100644
--- a/app/workers/archive_trace_worker.rb
+++ b/app/workers/archive_trace_worker.rb
@@ -1,16 +1,5 @@
# frozen_string_literal: true
-class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- sidekiq_options retry: 3
- include PipelineBackgroundQueue
-
- # rubocop: disable CodeReuse/ActiveRecord
- def perform(job_id)
- Ci::Build.without_archived_trace.find_by(id: job_id).try do |job|
- Ci::ArchiveTraceService.new.execute(job, worker_name: self.class.name)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
+class ArchiveTraceWorker < ::Ci::ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
+ # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/
end
diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
index 5ca9de63fd7..10f7cb20df0 100644
--- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
@@ -1,15 +1,54 @@
# frozen_string_literal: true
module AuthorizedProjectUpdate
- class UserRefreshFromReplicaWorker < ::AuthorizedProjectsWorker
+ class UserRefreshFromReplicaWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 3
feature_category :authentication_and_authorization
urgency :low
queue_namespace :authorized_project_update
- deduplicate :until_executing, including_scheduled: true
idempotent!
+ deduplicate :until_executing, including_scheduled: true
+
+ def perform(user_id)
+ if Feature.enabled?(:user_refresh_from_replica_worker_uses_replica_db)
+ use_replica_if_available do
+ user = User.find_by_id(user_id)
+
+ if user && project_authorizations_needs_refresh?(user)
+ enqueue_project_authorizations_refresh(user)
+ end
+ end
+ else
+ user = User.find_by_id(user_id)
+ return unless user
+
+ user.refresh_authorized_projects(source: self.class.name)
+ end
+ end
+
+ private
+
+ # We use this approach instead of specifying `data_consistency :delayed` because these jobs
+ # are enqueued in large numbers, and using `data_consistency :delayed`
+ # does not allow us to deduplicate these jobs.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/325291
+ def use_replica_if_available(&block)
+ return yield unless ::Gitlab::Database::LoadBalancing.enable?
+
+ ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block)
+ end
+
+ def project_authorizations_needs_refresh?(user)
+ AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(user).needs_refresh?
+ end
- # This worker will start reading data from the replica database soon
- # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/333219
+ def enqueue_project_authorizations_refresh(user)
+ with_context(user: user) do
+ AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker.perform_async(user.id)
+ end
+ end
end
end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index a3eaacec8a2..0d41f7b9438 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -1,61 +1,9 @@
# frozen_string_literal: true
-class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
+class BuildFinishedWorker < ::Ci::BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
+ # DEPRECATED: Not triggered since https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64934/
- sidekiq_options retry: 3
- include PipelineQueue
-
- queue_namespace :pipeline_processing
+ # We need to explicitly specify these settings. They aren't inheriting from the parent class.
urgency :high
worker_resource_boundary :cpu
-
- ARCHIVE_TRACES_IN = 2.minutes.freeze
-
- # rubocop: disable CodeReuse/ActiveRecord
- def perform(build_id)
- Ci::Build.find_by(id: build_id).try do |build|
- process_build(build)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- # Processes a single CI build that has finished.
- #
- # This logic resides in a separate method so that EE can extend it more
- # easily.
- #
- # @param [Ci::Build] build The build to process.
- def process_build(build)
- # We execute these in sync to reduce IO.
- build.parse_trace_sections!
- build.update_coverage
- Ci::BuildReportResultService.new.execute(build)
-
- # We execute these async as these are independent operations.
- BuildHooksWorker.perform_async(build.id)
- ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
-
- if build.failed?
- ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
- end
-
- ##
- # We want to delay sending a build trace to object storage operation to
- # validate that this fixes a race condition between this and flushing live
- # trace chunks and chunks being removed after consolidation and putting
- # them into object storage archive.
- #
- # TODO This is temporary fix we should improve later, after we validate
- # that this is indeed the culprit.
- #
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/267112 for more
- # details.
- #
- ArchiveTraceWorker.perform_in(ARCHIVE_TRACES_IN, build.id)
- end
end
-
-BuildFinishedWorker.prepend_mod_with('BuildFinishedWorker')
diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb
index aa3c03f773e..4ab08bbd7fe 100644
--- a/app/workers/build_queue_worker.rb
+++ b/app/workers/build_queue_worker.rb
@@ -10,7 +10,7 @@ class BuildQueueWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :continuous_integration
urgency :high
worker_resource_boundary :cpu
- data_consistency :sticky, feature_flag: :load_balancing_for_build_queue_worker
+ data_consistency :sticky
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb
index 24e75ad0f85..d3bb36d830f 100644
--- a/app/workers/bulk_imports/export_request_worker.rb
+++ b/app/workers/bulk_imports/export_request_worker.rb
@@ -25,7 +25,7 @@ module BulkImports
def http_client(configuration)
@client ||= Clients::HTTP.new(
- uri: configuration.url,
+ url: configuration.url,
token: configuration.access_token
)
end
diff --git a/app/workers/ci/archive_trace_worker.rb b/app/workers/ci/archive_trace_worker.rb
new file mode 100644
index 00000000000..16288faf370
--- /dev/null
+++ b/app/workers/ci/archive_trace_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 3
+ include PipelineBackgroundQueue
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(job_id)
+ Ci::Build.without_archived_trace.find_by(id: job_id).try do |job|
+ Ci::ArchiveTraceService.new.execute(job, worker_name: self.class.name)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb
index c748bc33ada..5fe3adf870f 100644
--- a/app/workers/ci/archive_traces_cron_worker.rb
+++ b/app/workers/ci/archive_traces_cron_worker.rb
@@ -12,7 +12,7 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def perform
# Archive stale live traces which still resides in redis or database
- # This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL
+ # This could happen when Ci::ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL
# More details in https://gitlab.com/gitlab-org/gitlab-foss/issues/36791
Ci::Build.with_stale_live_trace.find_each(batch_size: 100) do |build|
Ci::ArchiveTraceService.new.execute(build, worker_name: self.class.name)
diff --git a/app/workers/ci/build_finished_worker.rb b/app/workers/ci/build_finished_worker.rb
new file mode 100644
index 00000000000..1d6e3b1fa3c
--- /dev/null
+++ b/app/workers/ci/build_finished_worker.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 3
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+ urgency :high
+ worker_resource_boundary :cpu
+
+ ARCHIVE_TRACES_IN = 2.minutes.freeze
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(build_id)
+ Ci::Build.find_by(id: build_id).try do |build|
+ process_build(build)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ # Processes a single CI build that has finished.
+ #
+ # This logic resides in a separate method so that EE can extend it more
+ # easily.
+ #
+ # @param [Ci::Build] build The build to process.
+ def process_build(build)
+ # We execute these in sync to reduce IO.
+ build.parse_trace_sections!
+ build.update_coverage
+ Ci::BuildReportResultService.new.execute(build)
+
+ # We execute these async as these are independent operations.
+ BuildHooksWorker.perform_async(build.id)
+ ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
+
+ if build.failed?
+ ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
+ end
+
+ ##
+ # We want to delay sending a build trace to object storage operation to
+ # validate that this fixes a race condition between this and flushing live
+ # trace chunks and chunks being removed after consolidation and putting
+ # them into object storage archive.
+ #
+ # TODO This is temporary fix we should improve later, after we validate
+ # that this is indeed the culprit.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/267112 for more
+ # details.
+ #
+ archive_trace_worker_class(build).perform_in(ARCHIVE_TRACES_IN, build.id)
+ end
+
+ def archive_trace_worker_class(build)
+ if Feature.enabled?(:ci_build_finished_worker_namespace_changed, build.project, default_enabled: :yaml)
+ Ci::ArchiveTraceWorker
+ else
+ ::ArchiveTraceWorker
+ end
+ end
+ end
+end
+
+Ci::BuildFinishedWorker.prepend_mod_with('Ci::BuildFinishedWorker')
diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb
index 15ed89fd00e..ad0ed3d16f1 100644
--- a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb
+++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb
@@ -2,7 +2,10 @@
module Ci
module ResourceGroups
- class AssignResourceFromResourceGroupWorker # rubocop:disable Scalability/IdempotentWorker
+ # This worker is to assign a resource to a pipeline job from a resource group
+ # and enqueue the job to be executed by a runner.
+ # See https://docs.gitlab.com/ee/ci/yaml/#resource_group for more information.
+ class AssignResourceFromResourceGroupWorker
include ApplicationWorker
sidekiq_options retry: 3
@@ -11,6 +14,13 @@ module Ci
queue_namespace :pipeline_processing
feature_category :continuous_delivery
+ # This worker is idempotent that it produces the same result
+ # as long as the same resource group id is passed as an argument.
+ # Therefore, we can deduplicate the sidekiq jobs until the on-going
+ # assignment process has been finished.
+ idempotent!
+ deduplicate :until_executed
+
def perform(resource_group_id)
::Ci::ResourceGroup.find_by_id(resource_group_id).try do |resource_group|
Ci::ResourceGroups::AssignResourceFromResourceGroupService.new(resource_group.project, nil)
diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb
index d4d0ae96e03..a7073b78a81 100644
--- a/app/workers/clusters/applications/activate_service_worker.rb
+++ b/app/workers/clusters/applications/activate_service_worker.rb
@@ -15,7 +15,7 @@ module Clusters
return unless cluster
cluster.all_projects.find_each do |project|
- project.find_or_initialize_service(service_name).update!(active: true)
+ project.find_or_initialize_integration(service_name).update!(active: true)
end
end
end
diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb
index 935b455a4fc..9337af56623 100644
--- a/app/workers/clusters/applications/deactivate_service_worker.rb
+++ b/app/workers/clusters/applications/deactivate_service_worker.rb
@@ -10,18 +10,18 @@ module Clusters
loggable_arguments 1
- def perform(cluster_id, service_name)
+ def perform(cluster_id, integration_name)
cluster = Clusters::Cluster.find_by_id(cluster_id)
- raise cluster_missing_error(service_name) unless cluster
+ raise cluster_missing_error(integration_name) unless cluster
- service = "#{service_name}_service".to_sym
- cluster.all_projects.with_service(service).find_each do |project|
- project.public_send(service).update!(active: false) # rubocop:disable GitlabSecurity/PublicSend
+ integration = ::Project.integration_association_name(integration_name).to_sym
+ cluster.all_projects.with_integration(integration).find_each do |project|
+ project.public_send(integration).update!(active: false) # rubocop:disable GitlabSecurity/PublicSend
end
end
- def cluster_missing_error(service)
- ActiveRecord::RecordNotFound.new("Can't deactivate #{service} services, host cluster not found! Some inconsistent records may be left in database.")
+ def cluster_missing_error(integration_name)
+ ActiveRecord::RecordNotFound.new("Can't deactivate #{integration_name} integrations, host cluster not found! Some inconsistent records may be left in database.")
end
end
end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 3cba1eb31c5..e158ae0c298 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -47,11 +47,36 @@ module ApplicationWorker
end
class_methods do
+ extend ::Gitlab::Utils::Override
+
def inherited(subclass)
subclass.set_queue
subclass.after_set_class_attribute { subclass.set_queue }
end
+ override :validate_worker_attributes!
+ def validate_worker_attributes!
+ super
+
+ # Since the delayed data_consistency will use sidekiq built in retry mechanism, it is required that this mechanism
+ # is not disabled.
+ if retry_disabled? && get_data_consistency == :delayed
+ raise ArgumentError, "Retry support cannot be disabled if data_consistency is set to :delayed"
+ end
+ end
+
+ # Checks if sidekiq retry support is disabled
+ def retry_disabled?
+ get_sidekiq_options['retry'] == 0 || get_sidekiq_options['retry'] == false
+ end
+
+ override :sidekiq_options
+ def sidekiq_options(opts = {})
+ super.tap do
+ validate_worker_attributes!
+ end
+ end
+
def perform_async(*args)
# Worker execution for workers with data_consistency set to :delayed or :sticky
# will be delayed to give replication enough time to complete
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index 6ebf7c7c263..1eff53cea01 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -36,14 +36,15 @@ module Gitlab
importer_class.new(object, project, client).execute
- counter.increment
+ Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported)
+
info(project.id, message: 'importer finished')
rescue StandardError => e
error(project.id, e, hash)
end
- def counter
- @counter ||= Gitlab::Metrics.counter(counter_name, counter_description)
+ def object_type
+ raise NotImplementedError
end
# Returns the representation class to use for the object. This class must
@@ -57,16 +58,6 @@ module Gitlab
raise NotImplementedError
end
- # Returns the name (as a Symbol) of the Prometheus counter.
- def counter_name
- raise NotImplementedError
- end
-
- # Returns the description (as a String) of the Prometheus counter.
- def counter_description
- raise NotImplementedError
- end
-
private
attr_accessor :github_id
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
index e62bd8d9885..f8b945b8892 100644
--- a/app/workers/concerns/waitable_worker.rb
+++ b/app/workers/concerns/waitable_worker.rb
@@ -32,7 +32,9 @@ module WaitableWorker
failed = []
args_list.each do |args|
- new.perform(*args)
+ worker = new
+ Gitlab::AppJsonLogger.info(worker.structured_payload(message: 'running inline'))
+ worker.perform(*args)
rescue StandardError
failed << args
end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 096be808787..806fce38636 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -12,6 +12,7 @@ module WorkerAttributes
VALID_URGENCIES = [:high, :low, :throttled].freeze
VALID_DATA_CONSISTENCIES = [:always, :sticky, :delayed].freeze
+ DEFAULT_DATA_CONSISTENCY = :always
NAMESPACE_WEIGHTS = {
auto_devops: 2,
@@ -110,7 +111,7 @@ module WorkerAttributes
end
def get_data_consistency
- class_attributes[:data_consistency] || :always
+ class_attributes[:data_consistency] || DEFAULT_DATA_CONSISTENCY
end
def get_data_consistency_feature_flag_enabled?
diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
index 3027d46b8b1..33dda6a8f0c 100644
--- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -49,15 +49,11 @@ module ContainerExpirationPolicies
end
def remaining_work_count
- total_count = cleanup_scheduled_count + cleanup_unfinished_count
+ count = cleanup_scheduled_count
- log_info(
- cleanup_scheduled_count: cleanup_scheduled_count,
- cleanup_unfinished_count: cleanup_unfinished_count,
- cleanup_total_count: total_count
- )
+ return count if count > max_running_jobs
- total_count
+ count + cleanup_unfinished_count
end
private
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 8fc139ac87c..a35ca5d184e 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -17,6 +17,7 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
process_stale_ongoing_cleanups
disable_policies_without_container_repositories
throttling_enabled? ? perform_throttled : perform_unthrottled
+ log_counts
end
private
@@ -28,6 +29,26 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWo
end
end
+ def log_counts
+ use_replica_if_available do
+ required_count = ContainerRepository.requiring_cleanup.count
+ unfinished_count = ContainerRepository.with_unfinished_cleanup.count
+
+ log_extra_metadata_on_done(:cleanup_required_count, required_count)
+ log_extra_metadata_on_done(:cleanup_unfinished_count, unfinished_count)
+ log_extra_metadata_on_done(:cleanup_total_count, required_count + unfinished_count)
+ end
+ end
+
+ # data_consistency :delayed not used as this is a cron job and those jobs are
+ # not perfomed with a delay
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63635#note_603771207
+ def use_replica_if_available(&blk)
+ return yield unless ::Gitlab::Database::LoadBalancing.enable?
+
+ ::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&blk)
+ end
+
def process_stale_ongoing_cleanups
threshold = delete_tags_service_timeout.seconds + 30.minutes
ContainerRepository.with_stale_ongoing_cleanup(threshold.ago)
diff --git a/app/workers/database/partition_management_worker.rb b/app/workers/database/partition_management_worker.rb
new file mode 100644
index 00000000000..c9b1cd6d261
--- /dev/null
+++ b/app/workers/database/partition_management_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Database
+ class PartitionManagementWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: 3
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :database
+ idempotent!
+
+ def perform
+ Gitlab::Database::Partitioning::PartitionManager.new.sync_partitions
+ ensure
+ Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics
+ end
+ end
+end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 9702fac39ba..64f73d1fba1 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -10,7 +10,7 @@ class ExpirePipelineCacheWorker
queue_namespace :pipeline_cache
urgency :high
worker_resource_boundary :cpu
- data_consistency :delayed, feature_flag: :load_balancing_for_expire_pipeline_cache_worker
+ data_consistency :delayed
# This worker _should_ be idempotent, but due to us moving this to data_consistency :delayed
# and an ongoing incompatibility between the two switches, we need to disable this.
diff --git a/app/workers/gitlab/github_import/import_diff_note_worker.rb b/app/workers/gitlab/github_import/import_diff_note_worker.rb
index 25fb0375692..85b7d6c76bd 100644
--- a/app/workers/gitlab/github_import/import_diff_note_worker.rb
+++ b/app/workers/gitlab/github_import/import_diff_note_worker.rb
@@ -13,12 +13,8 @@ module Gitlab
Importer::DiffNoteImporter
end
- def counter_name
- :github_importer_imported_diff_notes
- end
-
- def counter_description
- 'The number of imported GitHub pull request review comments'
+ def object_type
+ :diff_note
end
end
end
diff --git a/app/workers/gitlab/github_import/import_issue_worker.rb b/app/workers/gitlab/github_import/import_issue_worker.rb
index d9c496e3eb3..8fdc0219ffd 100644
--- a/app/workers/gitlab/github_import/import_issue_worker.rb
+++ b/app/workers/gitlab/github_import/import_issue_worker.rb
@@ -13,12 +13,8 @@ module Gitlab
Importer::IssueAndLabelLinksImporter
end
- def counter_name
- :github_importer_imported_issues
- end
-
- def counter_description
- 'The number of imported GitHub issues'
+ def object_type
+ :issue
end
end
end
diff --git a/app/workers/gitlab/github_import/import_lfs_object_worker.rb b/app/workers/gitlab/github_import/import_lfs_object_worker.rb
index 78f78fdb160..2a95366bac7 100644
--- a/app/workers/gitlab/github_import/import_lfs_object_worker.rb
+++ b/app/workers/gitlab/github_import/import_lfs_object_worker.rb
@@ -13,12 +13,8 @@ module Gitlab
Importer::LfsObjectImporter
end
- def counter_name
- :github_importer_imported_lfs_objects
- end
-
- def counter_description
- 'The number of imported GitHub Lfs Objects'
+ def object_type
+ :lfs_object
end
end
end
diff --git a/app/workers/gitlab/github_import/import_note_worker.rb b/app/workers/gitlab/github_import/import_note_worker.rb
index d0f97a15afd..2125c953778 100644
--- a/app/workers/gitlab/github_import/import_note_worker.rb
+++ b/app/workers/gitlab/github_import/import_note_worker.rb
@@ -13,12 +13,8 @@ module Gitlab
Importer::NoteImporter
end
- def counter_name
- :github_importer_imported_notes
- end
-
- def counter_description
- 'The number of imported GitHub comments'
+ def object_type
+ :note
end
end
end
diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
index a8b79cf9b3a..91dab3470d9 100644
--- a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
@@ -15,12 +15,8 @@ module Gitlab
Importer::PullRequestMergedByImporter
end
- def counter_name
- :github_importer_imported_pull_requests_merged_by
- end
-
- def counter_description
- 'The number of imported GitHub pull requests merged by'
+ def object_type
+ :pull_request_merged_by
end
end
end
diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
index 5ee88d5d32b..de10fe40589 100644
--- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
@@ -15,12 +15,8 @@ module Gitlab
Importer::PullRequestReviewImporter
end
- def counter_name
- :github_importer_imported_pull_request_reviews
- end
-
- def counter_description
- 'The number of imported GitHub pull request reviews'
+ def object_type
+ :pull_request_review
end
end
end
diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb
index 9560874f247..79938a157d7 100644
--- a/app/workers/gitlab/github_import/import_pull_request_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb
@@ -13,12 +13,8 @@ module Gitlab
Importer::PullRequestImporter
end
- def counter_name
- :github_importer_imported_pull_requests
- end
-
- def counter_description
- 'The number of imported GitHub pull requests'
+ def object_type
+ :pull_request
end
end
end
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index f5980cc248e..f909d7e2f34 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -29,7 +29,8 @@ module Gitlab
info(
project.id,
message: "GitHub project import finished",
- duration_s: duration.round(2)
+ duration_s: duration.round(2),
+ object_counts: ::Gitlab::GithubImport::ObjectCounter.summary(project)
)
end
diff --git a/app/workers/gitlab/import/stuck_import_job.rb b/app/workers/gitlab/import/stuck_import_job.rb
index ac789ce1188..57fb3baf2b5 100644
--- a/app/workers/gitlab/import/stuck_import_job.rb
+++ b/app/workers/gitlab/import/stuck_import_job.rb
@@ -5,7 +5,7 @@ module Gitlab
module StuckImportJob
extend ActiveSupport::Concern
- IMPORT_JOBS_EXPIRATION = 15.hours.seconds.to_i
+ IMPORT_JOBS_EXPIRATION = 24.hours.seconds.to_i
included do
include ApplicationWorker
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_service_ping_worker.rb
index 782b089261f..a27629eac0a 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_service_ping_worker.rb
@@ -1,19 +1,19 @@
# frozen_string_literal: true
-class GitlabUsagePingWorker # rubocop:disable Scalability/IdempotentWorker
- LEASE_KEY = 'gitlab_usage_ping_worker:ping'
+class GitlabServicePingWorker # rubocop:disable Scalability/IdempotentWorker
+ LEASE_KEY = 'gitlab_service_ping_worker:ping'
LEASE_TIMEOUT = 86400
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
include Gitlab::ExclusiveLeaseHelpers
- feature_category :usage_ping
+ feature_category :service_ping
sidekiq_options retry: 3, dead: false
sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i }
def perform
- # Disable usage ping for GitLab.com
+ # Disable service ping for GitLab.com
# See https://gitlab.com/gitlab-org/gitlab/-/issues/292929 for details
return if Gitlab.com?
@@ -22,7 +22,7 @@ class GitlabUsagePingWorker # rubocop:disable Scalability/IdempotentWorker
# Splay the request over a minute to avoid thundering herd problems.
sleep(rand(0.0..60.0).round(3))
- SubmitUsagePingService.new.execute
+ ServicePing::SubmitService.new.execute
end
end
end
diff --git a/app/workers/jira_connect/forward_event_worker.rb b/app/workers/jira_connect/forward_event_worker.rb
new file mode 100644
index 00000000000..877ab46cfe5
--- /dev/null
+++ b/app/workers/jira_connect/forward_event_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class ForwardEventWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ queue_namespace :jira_connect
+ feature_category :integrations
+ worker_has_external_dependencies!
+
+ def perform(installation_id, base_path, event_path)
+ installation = JiraConnectInstallation.find_by_id(installation_id)
+
+ return if installation&.instance_url.nil?
+
+ proxy_url = installation.instance_url + event_path
+ qsh = Atlassian::Jwt.create_query_string_hash(proxy_url, 'POST', installation.instance_url + base_path)
+ jwt = Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret)
+
+ Gitlab::HTTP.post(proxy_url, headers: { 'Authorization' => "JWT #{jwt}" })
+ ensure
+ installation.destroy if installation
+ end
+ end
+end
diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb
index 4e8566d86c9..2723287b77b 100644
--- a/app/workers/jira_connect/sync_branch_worker.rb
+++ b/app/workers/jira_connect/sync_branch_worker.rb
@@ -1,16 +1,17 @@
# frozen_string_literal: true
module JiraConnect
- class SyncBranchWorker
+ class SyncBranchWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
queue_namespace :jira_connect
feature_category :integrations
+ data_consistency :delayed
loggable_arguments 1, 2
+
worker_has_external_dependencies!
- idempotent!
def perform(project_id, branch_name, commit_shas, update_sequence_id)
project = Project.find_by_id(project_id)
diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb
index 11a3b598035..4c4daba3314 100644
--- a/app/workers/jira_connect/sync_builds_worker.rb
+++ b/app/workers/jira_connect/sync_builds_worker.rb
@@ -1,18 +1,18 @@
# frozen_string_literal: true
module JiraConnect
- class SyncBuildsWorker
+ class SyncBuildsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
- idempotent!
- worker_has_external_dependencies!
-
queue_namespace :jira_connect
feature_category :integrations
+ data_consistency :delayed
tags :exclude_from_kubernetes
+ worker_has_external_dependencies!
+
def perform(pipeline_id, sequence_id)
pipeline = Ci::Pipeline.find_by_id(pipeline_id)
diff --git a/app/workers/jira_connect/sync_deployments_worker.rb b/app/workers/jira_connect/sync_deployments_worker.rb
index 9f75b1161f0..0dc34b5999f 100644
--- a/app/workers/jira_connect/sync_deployments_worker.rb
+++ b/app/workers/jira_connect/sync_deployments_worker.rb
@@ -1,18 +1,18 @@
# frozen_string_literal: true
module JiraConnect
- class SyncDeploymentsWorker
+ class SyncDeploymentsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
- idempotent!
- worker_has_external_dependencies!
-
queue_namespace :jira_connect
feature_category :integrations
+ data_consistency :delayed
tags :exclude_from_kubernetes
+ worker_has_external_dependencies!
+
def perform(deployment_id, sequence_id)
deployment = Deployment.find_by_id(deployment_id)
diff --git a/app/workers/jira_connect/sync_feature_flags_worker.rb b/app/workers/jira_connect/sync_feature_flags_worker.rb
index 0d8d3d3142e..c484cabbe6b 100644
--- a/app/workers/jira_connect/sync_feature_flags_worker.rb
+++ b/app/workers/jira_connect/sync_feature_flags_worker.rb
@@ -1,18 +1,18 @@
# frozen_string_literal: true
module JiraConnect
- class SyncFeatureFlagsWorker
+ class SyncFeatureFlagsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
- idempotent!
- worker_has_external_dependencies!
-
queue_namespace :jira_connect
feature_category :integrations
+ data_consistency :delayed
tags :exclude_from_kubernetes
+ worker_has_external_dependencies!
+
def perform(feature_flag_id, sequence_id)
feature_flag = ::Operations::FeatureFlag.find_by_id(feature_flag_id)
diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb
index bf31df2271f..bb0d24667e9 100644
--- a/app/workers/jira_connect/sync_merge_request_worker.rb
+++ b/app/workers/jira_connect/sync_merge_request_worker.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
module JiraConnect
- class SyncMergeRequestWorker
+ class SyncMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
queue_namespace :jira_connect
feature_category :integrations
- idempotent!
+ data_consistency :delayed
worker_has_external_dependencies!
diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb
index dfff0c4b3b6..317bace89b4 100644
--- a/app/workers/jira_connect/sync_project_worker.rb
+++ b/app/workers/jira_connect/sync_project_worker.rb
@@ -1,15 +1,16 @@
# frozen_string_literal: true
module JiraConnect
- class SyncProjectWorker
+ class SyncProjectWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
sidekiq_options retry: 3
queue_namespace :jira_connect
feature_category :integrations
+ data_consistency :delayed
tags :exclude_from_kubernetes
- idempotent!
+
worker_has_external_dependencies!
MERGE_REQUEST_LIMIT = 400
diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb
index 162c6dc2a88..408d070d56f 100644
--- a/app/workers/merge_request_cleanup_refs_worker.rb
+++ b/app/workers/merge_request_cleanup_refs_worker.rb
@@ -2,6 +2,8 @@
class MergeRequestCleanupRefsWorker
include ApplicationWorker
+ include LimitedCapacity::Worker
+ include Gitlab::Utils::StrongMemoize
sidekiq_options retry: 3
@@ -9,20 +11,60 @@ class MergeRequestCleanupRefsWorker
tags :exclude_from_kubernetes
idempotent!
- def perform(merge_request_id)
- return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false)
+ # Hard-coded to 4 for now. Will be configurable later on via application settings.
+ # This means, there can only be 4 jobs running at the same time at maximum.
+ MAX_RUNNING_JOBS = 4
+ FAILURE_THRESHOLD = 3
- merge_request = MergeRequest.find_by_id(merge_request_id)
+ def perform_work
+ return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false)
unless merge_request
- logger.error("Failed to find merge request with ID: #{merge_request_id}")
+ logger.error('No existing merge request to be cleaned up.')
return
end
- result = ::MergeRequests::CleanupRefsService.new(merge_request).execute
+ log_extra_metadata_on_done(:merge_request_id, merge_request.id)
+
+ result = MergeRequests::CleanupRefsService.new(merge_request).execute
+
+ if result[:status] == :success
+ merge_request_cleanup_schedule.complete!
+ else
+ if merge_request_cleanup_schedule.failed_count < FAILURE_THRESHOLD
+ merge_request_cleanup_schedule.retry!
+ else
+ merge_request_cleanup_schedule.mark_as_failed!
+ end
+
+ log_extra_metadata_on_done(:message, result[:message])
+ end
+
+ log_extra_metadata_on_done(:status, merge_request_cleanup_schedule.status)
+ end
+
+ def remaining_work_count
+ MergeRequest::CleanupSchedule
+ .scheduled_and_unstarted
+ .limit(max_running_jobs)
+ .count
+ end
+
+ def max_running_jobs
+ MAX_RUNNING_JOBS
+ end
+
+ private
- return if result[:status] == :success
+ def merge_request
+ strong_memoize(:merge_request) do
+ merge_request_cleanup_schedule&.merge_request
+ end
+ end
- logger.error("Failed cleanup refs of merge request (#{merge_request_id}): #{result[:message]}")
+ def merge_request_cleanup_schedule
+ strong_memoize(:merge_request_cleanup_schedule) do
+ MergeRequest::CleanupSchedule.start_next
+ end
end
end
diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb
index 7985325d1ad..1f46be29553 100644
--- a/app/workers/namespaces/in_product_marketing_emails_worker.rb
+++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb
@@ -14,7 +14,6 @@ module Namespaces
def perform
return if paid_self_managed_instance?
return if setting_disabled?
- return if experiment_inactive?
Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals
end
@@ -28,10 +27,6 @@ module Namespaces
def setting_disabled?
!Gitlab::CurrentSettings.in_product_marketing_emails_enabled
end
-
- def experiment_inactive?
- Gitlab.com? && !Gitlab::Experimentation.active?(:in_product_marketing_emails)
- end
end
end
diff --git a/app/workers/packages/helm/extraction_worker.rb b/app/workers/packages/helm/extraction_worker.rb
new file mode 100644
index 00000000000..fd4e720da94
--- /dev/null
+++ b/app/workers/packages/helm/extraction_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Packages
+ module Helm
+ class ExtractionWorker
+ include ApplicationWorker
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+ deduplicate :until_executing
+
+ idempotent!
+
+ def perform(channel, package_file_id)
+ package_file = ::Packages::PackageFile.find_by_id(package_file_id)
+
+ return unless package_file && !package_file.package.default?
+
+ ::Packages::Helm::ProcessFileService.new(channel, package_file).execute
+
+ rescue ::Packages::Helm::ExtractFileMetadataService::ExtractionError,
+ ::Packages::Helm::ProcessFileService::ExtractionError,
+ ::ActiveModel::ValidationError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
+ package_file.package.update_column(:status, :error)
+ end
+ end
+ end
+end
diff --git a/app/workers/partition_creation_worker.rb b/app/workers/partition_creation_worker.rb
index 2b21741d6c2..bb4834ab2dd 100644
--- a/app/workers/partition_creation_worker.rb
+++ b/app/workers/partition_creation_worker.rb
@@ -10,8 +10,7 @@ class PartitionCreationWorker
idempotent!
def perform
- Gitlab::Database::Partitioning::PartitionCreator.new.create_partitions
- ensure
- Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics
+ # This worker has been removed in favor of Database::PartitionManagementWorker
+ Database::PartitionManagementWorker.new.perform
end
end
diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb
index 97e6adbbf18..40d138752b4 100644
--- a/app/workers/pipeline_hooks_worker.rb
+++ b/app/workers/pipeline_hooks_worker.rb
@@ -8,7 +8,7 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker
queue_namespace :pipeline_hooks
worker_resource_boundary :cpu
- data_consistency :delayed, feature_flag: :load_balancing_for_pipeline_hooks_worker
+ data_consistency :delayed
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb
index 967be3b3e81..da38d2fc0cd 100644
--- a/app/workers/project_service_worker.rb
+++ b/app/workers/project_service_worker.rb
@@ -15,6 +15,6 @@ class ProjectServiceWorker # rubocop:disable Scalability/IdempotentWorker
integration.execute(data)
rescue StandardError => error
integration_class = integration&.class&.name || "Not Found"
- logger.error class: self.class.name, service_class: integration_class, message: error.message
+ Gitlab::ErrorTracking.log_exception(error, integration_class: integration_class)
end
end
diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb
index 1970f79729f..389e987e81a 100644
--- a/app/workers/projects/post_creation_worker.rb
+++ b/app/workers/projects/post_creation_worker.rb
@@ -15,21 +15,21 @@ module Projects
return unless project
- create_prometheus_service(project)
+ create_prometheus_integration(project)
end
private
- def create_prometheus_service(project)
- service = project.find_or_initialize_service(::PrometheusService.to_param)
+ def create_prometheus_integration(project)
+ integration = project.find_or_initialize_integration(::Integrations::Prometheus.to_param)
# If the service has already been inserted in the database, that
# means it came from a template, and there's nothing more to do.
- return if service.persisted?
+ return if integration.persisted?
- return unless service.prometheus_available?
+ return unless integration.prometheus_available?
- service.save!
+ integration.save!
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_exception(e, extra: { project_id: project.id })
end
diff --git a/app/workers/prometheus/create_default_alerts_worker.rb b/app/workers/prometheus/create_default_alerts_worker.rb
index 0dba752ced1..9d163cd828e 100644
--- a/app/workers/prometheus/create_default_alerts_worker.rb
+++ b/app/workers/prometheus/create_default_alerts_worker.rb
@@ -15,7 +15,7 @@ module Prometheus
return unless project
- result = Prometheus::CreateDefaultAlertsService.new(project: project).execute
+ result = ::Prometheus::CreateDefaultAlertsService.new(project: project).execute
log_info(result.message) if result.error?
end
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index a9a8201205e..31d68e65b23 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -46,7 +46,7 @@ module RepositoryCheck
true
rescue Gitlab::Git::Repository::GitError => e
- Gitlab::RepositoryCheckLogger.error(e.message)
+ Gitlab::RepositoryCheckLogger.error("#{repository.full_path}: #{e.message}")
false
end
diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
index b5ea5298879..40a773ca58f 100644
--- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb
+++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
@@ -10,21 +10,10 @@ class ScheduleMergeRequestCleanupRefsWorker
tags :exclude_from_kubernetes
idempotent!
- # Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per
- # second. This means that 180 jobs can be performed but since there are some
- # spikes from time time, it's better to give it some allowance.
- LIMIT = 180
- DELAY = 10.seconds
- BATCH_SIZE = 30
-
def perform
return if Gitlab::Database.read_only?
return unless Feature.enabled?(:merge_request_refs_cleanup, default_enabled: false)
- ids = MergeRequest::CleanupSchedule.scheduled_merge_request_ids(LIMIT).map { |id| [id] }
-
- MergeRequestCleanupRefsWorker.bulk_perform_in(DELAY, ids, batch_size: BATCH_SIZE) # rubocop:disable Scalability/BulkPerformWithContext
-
- log_extra_metadata_on_done(:merge_requests_count, ids.size)
+ MergeRequestCleanupRefsWorker.perform_with_capacity
end
end