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:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/shibboleth_64.pngbin0 -> 2993 bytes
-rw-r--r--app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue6
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue2
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue29
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_actions.vue206
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_header.vue62
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/reported_content.vue9
-rw-r--r--app/assets/javascripts/admin/abuse_report/constants.js50
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue177
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js10
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue24
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/constants.js1
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/edit.js5
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue3
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/constants.js1
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue190
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js58
-rw-r--r--app/assets/javascripts/api.js7
-rw-r--r--app/assets/javascripts/api/user_api.js11
-rw-r--r--app/assets/javascripts/artifacts_settings/index.js4
-rw-r--r--app/assets/javascripts/artifacts_settings/keep_latest_artifact_toggle.vue (renamed from app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue)24
-rw-r--r--app/assets/javascripts/authentication/password/components/password_input.vue10
-rw-r--r--app/assets/javascripts/batch_comments/components/diff_file_drafts.vue15
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue4
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js12
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js3
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js1
-rw-r--r--app/assets/javascripts/behaviors/markdown/utils.js27
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js43
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js2
-rw-r--r--app/assets/javascripts/blame/streaming/index.js4
-rw-r--r--app/assets/javascripts/blob/components/table_contents.vue5
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js29
-rw-r--r--app/assets/javascripts/boards/boards_util.js56
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue132
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue11
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue148
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue210
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue12
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue90
-rw-r--r--app/assets/javascripts/boards/constants.js16
-rw-r--r--app/assets/javascripts/boards/graphql/cache_updates.js118
-rw-r--r--app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql4
-rw-r--r--app/assets/javascripts/boards/stores/actions.js8
-rw-r--r--app/assets/javascripts/branches/components/branch_more_actions.vue114
-rw-r--r--app/assets/javascripts/branches/components/delete_branch_button.vue85
-rw-r--r--app/assets/javascripts/branches/components/delete_merged_branches.vue10
-rw-r--r--app/assets/javascripts/branches/components/sort_dropdown.vue2
-rw-r--r--app/assets/javascripts/branches/init_branch_more_actions.js (renamed from app/assets/javascripts/branches/init_delete_branch_button.js)16
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_row.vue5
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue64
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_checkbox.vue4
-rw-r--r--app/assets/javascripts/ci/artifacts/constants.js2
-rw-r--r--app/assets/javascripts/ci/artifacts/utils.js7
-rw-r--r--app/assets/javascripts/ci/ci_lint/components/ci_lint.vue2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue34
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue163
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js18
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js1
-rw-r--r--app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue110
-rw-r--r--app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql24
-rw-r--r--app/assets/javascripts/ci/inherited_ci_variables/index.js36
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue13
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue32
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue17
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue19
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue21
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue14
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue23
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js23
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue14
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue12
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js6
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js52
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js141
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/options.js142
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue84
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue2
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue11
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue9
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_create_form.vue41
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_modal.vue49
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_details.vue44
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_form_fields.vue214
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_header.vue38
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue8
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue79
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_managers_badge.vue47
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_managers_detail.vue111
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_managers_table.vue75
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_name.vue12
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue16
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_status_badge.vue20
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue143
-rw-r--r--app/assets/javascripts/ci/runner/constants.js26
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql5
-rw-r--r--app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql (renamed from app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql)4
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql5
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql5
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql12
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql13
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue4
-rw-r--r--app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/runner_update_form_utils.js4
-rw-r--r--app/assets/javascripts/ci/runner/utils.js11
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue274
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue74
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue3
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue7
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue11
-rw-r--r--app/assets/javascripts/clusters_list/constants.js4
-rw-r--r--app/assets/javascripts/clusters_list/graphql/cache_update.js28
-rw-r--r--app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql1
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql28
-rw-r--r--app/assets/javascripts/code_review/signals.js5
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue218
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue73
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue89
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue16
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference_label.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue156
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/code.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_item.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/drawio_diagram.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js102
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js84
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js2
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js39
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js24
-rw-r--r--app/assets/javascripts/content_editor/services/highlight_js_language_loader.js4
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js43
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js52
-rw-r--r--app/assets/javascripts/contextual_sidebar.js22
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue65
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue54
-rw-r--r--app/assets/javascripts/contribution_events/components/contribution_events.vue119
-rw-r--r--app/assets/javascripts/contribution_events/components/resource_parent_link.vue22
-rw-r--r--app/assets/javascripts/contribution_events/components/target_link.vue31
-rw-r--r--app/assets/javascripts/contribution_events/constants.js14
-rw-r--r--app/assets/javascripts/crm/components/crm_form.vue12
-rw-r--r--app/assets/javascripts/deprecated_notes.js43
-rw-r--r--app/assets/javascripts/design_management/components/design_description/description_form.vue234
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue13
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue64
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue10
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue12
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue4
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql11
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql4
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js2
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue15
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js2
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js4
-rw-r--r--app/assets/javascripts/diffs/components/app.vue113
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue46
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue88
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue22
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue6
-rw-r--r--app/assets/javascripts/diffs/components/shared/findings_drawer.vue6
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue71
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/index.js32
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js7
-rw-r--r--app/assets/javascripts/diffs/store/actions.js49
-rw-r--r--app/assets/javascripts/diffs/store/getters.js14
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js42
-rw-r--r--app/assets/javascripts/diffs/store/utils.js7
-rw-r--r--app/assets/javascripts/diffs/utils/diff_file.js3
-rw-r--r--app/assets/javascripts/drawio/constants.js15
-rw-r--r--app/assets/javascripts/drawio/drawio_editor.js19
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue19
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar_button.vue14
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js1
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js23
-rw-r--r--app/assets/javascripts/editor/schema/ci.json69
-rw-r--r--app/assets/javascripts/ensure_data.js2
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue96
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue24
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue99
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue28
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue24
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue14
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue22
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue14
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue24
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_agent_info.vue52
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue44
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_pods.vue24
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_status_bar.vue39
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_summary.vue19
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_tabs.vue7
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue45
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue76
-rw-r--r--app/assets/javascripts/environments/constants.js19
-rw-r--r--app/assets/javascripts/environments/edit.js31
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue1
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql9
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql9
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment.query.graphql14
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql19
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql13
-rw-r--r--app/assets/javascripts/environments/mount_show.js1
-rw-r--r--app/assets/javascripts/environments/new.js18
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue49
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details_info.vue57
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_actions.vue24
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue120
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue37
-rw-r--r--app/assets/javascripts/error_tracking/components/timeline_chart.vue129
-rw-r--r--app/assets/javascripts/error_tracking/details.js5
-rw-r--r--app/assets/javascripts/error_tracking/events_tracking.js47
-rw-r--r--app/assets/javascripts/error_tracking/list.js3
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql4
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue20
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue54
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_popover.vue2
-rw-r--r--app/assets/javascripts/google_cloud/databases/service_table.vue2
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue131
-rw-r--r--app/assets/javascripts/grafana_integration/index.js17
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js44
-rw-r--r--app/assets/javascripts/grafana_integration/store/index.js16
-rw-r--r--app/assets/javascripts/grafana_integration/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/grafana_integration/store/mutations.js13
-rw-r--r--app/assets/javascripts/grafana_integration/store/state.js8
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js42
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json6
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql2
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql2
-rw-r--r--app/assets/javascripts/groups/components/app.vue4
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue2
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue2
-rw-r--r--app/assets/javascripts/groups/index.js7
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/header_search/components/app.vue2
-rw-r--r--app/assets/javascripts/header_search/index.js4
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/getters.js4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue2
-rw-r--r--app/assets/javascripts/integrations/constants.js27
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue152
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue35
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/connection.vue36
-rw-r--r--app/assets/javascripts/integrations/gitlab_slack_application/api.js7
-rw-r--r--app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue135
-rw-r--r--app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue55
-rw-r--r--app/assets/javascripts/integrations/gitlab_slack_application/constants.js15
-rw-r--r--app/assets/javascripts/integrations/gitlab_slack_application/index.js34
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue48
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue6
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_modal.js8
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue51
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue5
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/issues/constants.js1
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue19
-rw-r--r--app/assets/javascripts/issues/dashboard/index.js6
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql5
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql56
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue8
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue16
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue91
-rw-r--r--app/assets/javascripts/issues/list/constants.js6
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql5
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue29
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue60
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue38
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue6
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue1
-rw-r--r--app/assets/javascripts/issues/show/index.js1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue19
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue5
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js27
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue10
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue6
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue25
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/job_cell.vue24
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue9
-rw-r--r--app/assets/javascripts/layout_nav.js47
-rw-r--r--app/assets/javascripts/lib/apollo/persistence_mapper.js4
-rw-r--r--app/assets/javascripts/lib/graphql.js5
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js18
-rw-r--r--app/assets/javascripts/lib/utils/constants.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js10
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/listbox_helpers.js45
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js26
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js9
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js80
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue18
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue18
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue27
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue63
-rw-r--r--app/assets/javascripts/merge_request.js11
-rw-r--r--app/assets/javascripts/merge_request_tabs.js1
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue9
-rw-r--r--app/assets/javascripts/milestones/index.js26
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue79
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js6
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue40
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue34
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue2
-rw-r--r--app/assets/javascripts/mr_more_dropdown.js57
-rw-r--r--app/assets/javascripts/mr_notes/init.js29
-rw-r--r--app/assets/javascripts/mr_notes/init_mr_notes.js2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue8
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue6
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue89
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue12
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue3
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue38
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue42
-rw-r--r--app/assets/javascripts/notes/i18n.js6
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js33
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js36
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js52
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/notes/stores/state.js53
-rw-r--r--app/assets/javascripts/notes/utils.js17
-rw-r--r--app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue60
-rw-r--r--app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue48
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue55
-rw-r--r--app/assets/javascripts/operation_settings/index.js17
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js41
-rw-r--r--app/assets/javascripts/operation_settings/store/index.js16
-rw-r--r--app/assets/javascripts/operation_settings/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/operation_settings/store/mutations.js10
-rw-r--r--app/assets/javascripts/operation_settings/store/state.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue31
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue269
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql15
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql20
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue180
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue2
-rw-r--r--app/assets/javascripts/pages/admin/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/constants.js12
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/topics/new/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue10
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js2
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/slacks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/cluster_health.js18
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue165
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue88
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js5
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql5
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql6
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/work_items/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue7
-rw-r--r--app/assets/javascripts/pages/shared/wikis/edit.js1
-rw-r--r--app/assets/javascripts/pages/users/index.js9
-rw-r--r--app/assets/javascripts/pages/users/show/index.js6
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js5
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_details_header.vue629
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue149
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue143
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue107
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue94
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue41
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue129
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue53
-rw-r--r--app/assets/javascripts/pipelines/constants.js4
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql (renamed from app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql)0
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql34
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql9
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql (renamed from app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql)0
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js34
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js71
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js2
-rw-r--r--app/assets/javascripts/profile/components/follow.vue88
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue54
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue4
-rw-r--r--app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql39
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue45
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue4
-rw-r--r--app/assets/javascripts/profile/components/snippets/snippet_row.vue119
-rw-r--r--app/assets/javascripts/profile/components/snippets/snippets_tab.vue110
-rw-r--r--app/assets/javascripts/profile/components/snippets_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/user_achievements.vue69
-rw-r--r--app/assets/javascripts/profile/constants.js2
-rw-r--r--app/assets/javascripts/profile/edit/components/profile_edit_app.vue10
-rw-r--r--app/assets/javascripts/profile/edit/index.js16
-rw-r--r--app/assets/javascripts/profile/index.js21
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue160
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue45
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue134
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/refs_list.vue112
-rw-r--r--app/assets/javascripts/projects/commit_box/info/constants.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql10
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql10
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql19
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js6
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_references.js32
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js24
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue55
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue8
-rw-r--r--app/assets/javascripts/projects/project_new.js2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js2
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue31
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue4
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue34
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js2
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js6
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue1
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue4
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js24
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue32
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js47
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js3
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue5
-rw-r--r--app/assets/javascripts/search/index.js2
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue34
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue2
-rw-r--r--app/assets/javascripts/search/sidebar/components/issues_filters.vue85
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/data.js23
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/index.vue291
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue43
-rw-r--r--app/assets/javascripts/search/sidebar/components/label_filter/tracking.js21
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue91
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/index.vue121
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue4
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue (renamed from app/assets/javascripts/search/sidebar/components/scope_navigation.vue)2
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue (renamed from app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue)2
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue2
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js7
-rw-r--r--app/assets/javascripts/search/sort/components/app.vue58
-rw-r--r--app/assets/javascripts/search/store/actions.js19
-rw-r--r--app/assets/javascripts/search/store/constants.js2
-rw-r--r--app/assets/javascripts/search/store/getters.js38
-rw-r--r--app/assets/javascripts/search/store/index.js4
-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.js5
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue11
-rw-r--r--app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue2
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js6
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue6
-rw-r--r--app/assets/javascripts/security_configuration/components/training_provider_list.vue2
-rw-r--r--app/assets/javascripts/sentry/index.js2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue115
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/status/status_dropdown.vue45
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue40
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js4
-rw-r--r--app/assets/javascripts/snippets/components/show.vue11
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue10
-rw-r--r--app/assets/javascripts/snippets/constants.js4
-rw-r--r--app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js80
-rw-r--r--app/assets/javascripts/super_sidebar/components/brand_logo.vue45
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue18
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue191
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue42
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue57
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js47
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue39
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue19
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue15
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue11
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue27
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/popper_max_size_modifier.js43
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js6
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js2
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue2
-rw-r--r--app/assets/javascripts/tags/components/sort_dropdown.vue32
-rw-r--r--app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js46
-rw-r--r--app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue87
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue10
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue8
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue19
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js16
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql8
-rw-r--r--app/assets/javascripts/usage_quotas/storage/utils.js20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue69
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue43
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/i18n.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue116
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue76
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js33
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue17
-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/label_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue361
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue129
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue222
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue227
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue64
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue73
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue73
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue17
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue11
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue10
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js2
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue110
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue17
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue41
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue14
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue27
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue138
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue13
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue70
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_award_emoji.vue173
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue173
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue100
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue105
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue117
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue6
-rw-r--r--app/assets/javascripts/work_items/constants.js13
-rw-r--r--app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js38
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql7
-rw-r--r--app/assets/javascripts/work_items/mixins/description_version_history.js14
-rw-r--r--app/assets/javascripts/work_items/notes/collapse_utils.js92
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue15
-rw-r--r--app/assets/javascripts/work_items/router/routes.js2
-rw-r--r--app/assets/javascripts/work_items/utils.js8
-rw-r--r--app/assets/stylesheets/components/content_editor.scss42
-rw-r--r--app/assets/stylesheets/components/whats_new.scss1
-rw-r--r--app/assets/stylesheets/fonts.scss30
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss4
-rw-r--r--app/assets/stylesheets/framework/common.scss4
-rw-r--r--app/assets/stylesheets/framework/diffs.scss2
-rw-r--r--app/assets/stylesheets/framework/files.scss7
-rw-r--r--app/assets/stylesheets/framework/filters.scss17
-rw-r--r--app/assets/stylesheets/framework/header.scss2
-rw-r--r--app/assets/stylesheets/framework/layout.scss5
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss1
-rw-r--r--app/assets/stylesheets/framework/mixins.scss13
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss8
-rw-r--r--app/assets/stylesheets/framework/timeline.scss5
-rw-r--r--app/assets/stylesheets/framework/variables.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss4
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss4
-rw-r--r--app/assets/stylesheets/notify_enhanced.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_settings.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss21
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss29
-rw-r--r--app/assets/stylesheets/page_bundles/error_tracking_details.scss31
-rw-r--r--app/assets/stylesheets/page_bundles/error_tracking_index.scss18
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss41
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss75
-rw-r--r--app/assets/stylesheets/page_bundles/search.scss59
-rw-r--r--app/assets/stylesheets/page_bundles/web_ide_loader.scss38
-rw-r--r--app/assets/stylesheets/pages/note_form.scss8
-rw-r--r--app/assets/stylesheets/pages/notes.scss4
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss6
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss51
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss33
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss62
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss5
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss4
-rw-r--r--app/assets/stylesheets/themes/theme_light_gray.scss2
-rw-r--r--app/assets/stylesheets/utilities.scss18
-rw-r--r--app/components/diffs/base_component.rb2
-rw-r--r--app/components/layouts/horizontal_section_component.rb2
-rw-r--r--app/components/pajamas/alert_component.html.haml8
-rw-r--r--app/components/pajamas/alert_component.rb8
-rw-r--r--app/components/pajamas/component.rb2
-rw-r--r--app/controllers/abuse_reports_controller.rb4
-rw-r--r--app/controllers/admin/abuse_reports_controller.rb7
-rw-r--r--app/controllers/admin/background_migrations_controller.rb2
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb1
-rw-r--r--app/controllers/admin/groups_controller.rb2
-rw-r--r--app/controllers/admin/hooks_controller.rb1
-rw-r--r--app/controllers/admin/projects_controller.rb2
-rw-r--r--app/controllers/admin/topics/avatars_controller.rb2
-rw-r--r--app/controllers/admin/topics_controller.rb8
-rw-r--r--app/controllers/admin/users_controller.rb14
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/clusters/base_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/creates_commit.rb2
-rw-r--r--app/controllers/concerns/impersonation.rb2
-rw-r--r--app/controllers/concerns/integrations/actions.rb9
-rw-r--r--app/controllers/concerns/integrations/params.rb1
-rw-r--r--app/controllers/concerns/membership_actions.rb2
-rw-r--r--app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb51
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb2
-rw-r--r--app/controllers/concerns/notes_actions.rb22
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/search_rate_limitable.rb4
-rw-r--r--app/controllers/concerns/skips_already_signed_in_message.rb24
-rw-r--r--app/controllers/concerns/snippets_actions.rb2
-rw-r--r--app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb5
-rw-r--r--app/controllers/concerns/uploads_actions.rb12
-rw-r--r--app/controllers/concerns/web_hooks/hook_actions.rb1
-rw-r--r--app/controllers/concerns/web_hooks/hook_log_actions.rb2
-rw-r--r--app/controllers/concerns/web_ide_csp.rb34
-rw-r--r--app/controllers/concerns/wiki_actions.rb3
-rw-r--r--app/controllers/dashboard/groups_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/dashboard_controller.rb4
-rw-r--r--app/controllers/explore/groups_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb6
-rw-r--r--app/controllers/graphql_controller.rb60
-rw-r--r--app/controllers/groups/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/groups/avatars_controller.rb2
-rw-r--r--app/controllers/groups/children_controller.rb2
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb2
-rw-r--r--app/controllers/groups/group_links_controller.rb2
-rw-r--r--app/controllers/groups/group_members_controller.rb2
-rw-r--r--app/controllers/groups/milestones_controller.rb14
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb4
-rw-r--r--app/controllers/groups/shared_projects_controller.rb2
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/groups/usage_quotas_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb7
-rw-r--r--app/controllers/jira_connect/app_descriptor_controller.rb7
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb42
-rw-r--r--app/controllers/organizations/application_controller.rb21
-rw-r--r--app/controllers/organizations/organizations_controller.rb11
-rw-r--r--app/controllers/profiles/preferences_controller.rb1
-rw-r--r--app/controllers/profiles/slacks_controller.rb32
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb4
-rw-r--r--app/controllers/profiles/webauthn_registrations_controller.rb3
-rw-r--r--app/controllers/projects/artifacts_controller.rb4
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/avatars_controller.rb2
-rw-r--r--app/controllers/projects/blame_controller.rb5
-rw-r--r--app/controllers/projects/blob_controller.rb1
-rw-r--r--app/controllers/projects/branches_controller.rb10
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/commit_controller.rb5
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb1
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb20
-rw-r--r--app/controllers/projects/environments_controller.rb64
-rw-r--r--app/controllers/projects/group_links_controller.rb2
-rw-r--r--app/controllers/projects/hooks_controller.rb1
-rw-r--r--app/controllers/projects/issues_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb12
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb13
-rw-r--r--app/controllers/projects/metrics_dashboard_controller.rb55
-rw-r--r--app/controllers/projects/milestones_controller.rb11
-rw-r--r--app/controllers/projects/ml/candidates_controller.rb6
-rw-r--r--app/controllers/projects/ml/experiments_controller.rb6
-rw-r--r--app/controllers/projects/pages_domains_controller.rb3
-rw-r--r--app/controllers/projects/pipelines_controller.rb7
-rw-r--r--app/controllers/projects/project_members_controller.rb2
-rw-r--r--app/controllers/projects/prometheus/alerts_controller.rb21
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb5
-rw-r--r--app/controllers/projects/redirect_controller.rb2
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/settings/branch_rules_controller.rb4
-rw-r--r--app/controllers/projects/settings/operations_controller.rb12
-rw-r--r--app/controllers/projects/settings/repository_controller.rb1
-rw-r--r--app/controllers/projects/settings/slacks_controller.rb78
-rw-r--r--app/controllers/projects/starrers_controller.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/controllers/projects_controller.rb17
-rw-r--r--app/controllers/registrations/welcome_controller.rb37
-rw-r--r--app/controllers/registrations_controller.rb1
-rw-r--r--app/controllers/search_controller.rb4
-rw-r--r--app/controllers/sent_notifications_controller.rb4
-rw-r--r--app/controllers/sessions_controller.rb13
-rw-r--r--app/controllers/uploads_controller.rb2
-rw-r--r--app/experiments/templates/new_project_readme_content/readme_advanced.md.tt2
-rw-r--r--app/finders/alert_management/http_integrations_finder.rb27
-rw-r--r--app/finders/crm/organizations_finder.rb38
-rw-r--r--app/finders/deployments_finder.rb16
-rw-r--r--app/finders/groups/environment_scopes_finder.rb50
-rw-r--r--app/finders/groups_finder.rb32
-rw-r--r--app/finders/merge_requests_finder.rb12
-rw-r--r--app/finders/namespaces/projects_finder.rb2
-rw-r--r--app/finders/releases_finder.rb30
-rw-r--r--app/finders/template_finder.rb12
-rw-r--r--app/finders/uploader_finder.rb4
-rw-r--r--app/finders/users_finder.rb10
-rw-r--r--app/graphql/cached_introspection_query.rb107
-rw-r--r--app/graphql/graphql_triggers.rb10
-rw-r--r--app/graphql/mutations/achievements/delete_user_achievement.rb33
-rw-r--r--app/graphql/mutations/ci/ci_cd_settings_update.rb17
-rw-r--r--app/graphql/mutations/ci/job_artifact/bulk_destroy.rb5
-rw-r--r--app/graphql/mutations/ci/pipeline/cancel.rb6
-rw-r--r--app/graphql/mutations/dependency_proxy/group_settings/update.rb9
-rw-r--r--app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb6
-rw-r--r--app/graphql/mutations/environments/create.rb58
-rw-r--r--app/graphql/mutations/environments/delete.rb29
-rw-r--r--app/graphql/mutations/environments/update.rb61
-rw-r--r--app/graphql/mutations/issues/create.rb4
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb7
-rw-r--r--app/graphql/mutations/issues/update.rb3
-rw-r--r--app/graphql/mutations/namespace/package_settings/update.rb6
-rw-r--r--app/graphql/mutations/notes/update/base.rb7
-rw-r--r--app/graphql/mutations/projects/sync_fork.rb3
-rw-r--r--app/graphql/mutations/snippets/create.rb3
-rw-r--r--app/graphql/mutations/snippets/update.rb3
-rw-r--r--app/graphql/mutations/users/set_namespace_commit_email.rb44
-rw-r--r--app/graphql/mutations/work_items/convert.rb4
-rw-r--r--app/graphql/mutations/work_items/create.rb2
-rw-r--r--app/graphql/mutations/work_items/create_from_task.rb5
-rw-r--r--app/graphql/mutations/work_items/update.rb3
-rw-r--r--app/graphql/mutations/work_items/update_task.rb4
-rw-r--r--app/graphql/queries/snippet/snippet.query.graphql1
-rw-r--r--app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb13
-rw-r--r--app/graphql/resolvers/blobs_resolver.rb14
-rw-r--r--app/graphql/resolvers/group_environment_scopes_resolver.rb23
-rw-r--r--app/graphql/resolvers/last_commit_resolver.rb3
-rw-r--r--app/graphql/resolvers/namespace_projects_resolver.rb6
-rw-r--r--app/graphql/resolvers/noteable/notes_resolver.rb36
-rw-r--r--app/graphql/resolvers/paginated_tree_resolver.rb12
-rw-r--r--app/graphql/resolvers/timelog_resolver.rb7
-rw-r--r--app/graphql/resolvers/tree_resolver.rb8
-rw-r--r--app/graphql/subscriptions/work_item_updated.rb21
-rw-r--r--app/graphql/types/alert_management/alert_type.rb4
-rw-r--r--app/graphql/types/audit_events/definition_type.rb50
-rw-r--r--app/graphql/types/ci/catalog/resource_type.rb27
-rw-r--r--app/graphql/types/ci/group_environment_scope_connection_type.rb10
-rw-r--r--app/graphql/types/ci/group_environment_scope_type.rb18
-rw-r--r--app/graphql/types/ci/job_artifact_type.rb2
-rw-r--r--app/graphql/types/ci/job_type.rb2
-rw-r--r--app/graphql/types/ci/runner_manager_type.rb2
-rw-r--r--app/graphql/types/environment_type.rb7
-rw-r--r--app/graphql/types/global_id_type.rb6
-rw-r--r--app/graphql/types/group_type.rb16
-rw-r--r--app/graphql/types/mutation_type.rb10
-rw-r--r--app/graphql/types/notes/note_type.rb15
-rw-r--r--app/graphql/types/notes/noteable_interface.rb2
-rw-r--r--app/graphql/types/permission_types/work_item.rb3
-rw-r--r--app/graphql/types/query_type.rb6
-rw-r--r--app/graphql/types/ref_type_enum.rb11
-rw-r--r--app/graphql/types/subscription_type.rb5
-rw-r--r--app/graphql/types/time_tracking/timelog_sort_enum.rb14
-rw-r--r--app/graphql/types/user_interface.rb35
-rw-r--r--app/helpers/admin/application_settings/settings_helper.rb49
-rw-r--r--app/helpers/appearances_helper.rb8
-rw-r--r--app/helpers/application_helper.rb15
-rw-r--r--app/helpers/application_settings_helper.rb12
-rw-r--r--app/helpers/auth_helper.rb4
-rw-r--r--app/helpers/avatars_helper.rb19
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/branches_helper.rb21
-rw-r--r--app/helpers/broadcast_messages_helper.rb3
-rw-r--r--app/helpers/ci/catalog/resources_helper.rb4
-rw-r--r--app/helpers/ci/pipelines_helper.rb11
-rw-r--r--app/helpers/ci/runners_helper.rb7
-rw-r--r--app/helpers/ci/secure_files_helper.rb1
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/form_helper.rb2
-rw-r--r--app/helpers/groups_helper.rb4
-rw-r--r--app/helpers/ide_helper.rb12
-rw-r--r--app/helpers/integrations_helper.rb27
-rw-r--r--app/helpers/invite_members_helper.rb5
-rw-r--r--app/helpers/issuables_helper.rb6
-rw-r--r--app/helpers/issues_helper.rb4
-rw-r--r--app/helpers/members_helper.rb2
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/preferences_helper.rb3
-rw-r--r--app/helpers/profiles_helper.rb5
-rw-r--r--app/helpers/projects/error_tracking_helper.rb13
-rw-r--r--app/helpers/projects/pipeline_helper.rb24
-rw-r--r--app/helpers/projects/topics_helper.rb19
-rw-r--r--app/helpers/projects_helper.rb16
-rw-r--r--app/helpers/registrations_helper.rb5
-rw-r--r--app/helpers/resource_events/abuse_report_events_helper.rb24
-rw-r--r--app/helpers/safe_format_helper.rb66
-rw-r--r--app/helpers/search_helper.rb96
-rw-r--r--app/helpers/ssh_keys_helper.rb2
-rw-r--r--app/helpers/tree_helper.rb6
-rw-r--r--app/helpers/users/callouts_helper.rb13
-rw-r--r--app/helpers/users_helper.rb20
-rw-r--r--app/mailers/emails/service_desk.rb8
-rw-r--r--app/models/abuse/event.rb18
-rw-r--r--app/models/abuse/trust_score.rb1
-rw-r--r--app/models/abuse_report.rb17
-rw-r--r--app/models/alert_management/http_integration.rb28
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb4
-rw-r--r--app/models/analytics/cycle_analytics/value_stream.rb1
-rw-r--r--app/models/application_setting.rb72
-rw-r--r--app/models/application_setting_implementation.rb9
-rw-r--r--app/models/audit_event.rb34
-rw-r--r--app/models/blob.rb3
-rw-r--r--app/models/blob_viewer/geo_json.rb12
-rw-r--r--app/models/blob_viewer/metrics_dashboard_yml.rb45
-rw-r--r--app/models/broadcast_message.rb13
-rw-r--r--app/models/ci/build.rb37
-rw-r--r--app/models/ci/catalog/listing.rb17
-rw-r--r--app/models/ci/catalog/resource.rb9
-rw-r--r--app/models/ci/group_variable.rb13
-rw-r--r--app/models/ci/job_annotation.rb19
-rw-r--r--app/models/ci/job_artifact.rb4
-rw-r--r--app/models/ci/pipeline.rb67
-rw-r--r--app/models/ci/runner.rb5
-rw-r--r--app/models/ci/secure_file.rb5
-rw-r--r--app/models/clusters/agent.rb33
-rw-r--r--app/models/clusters/cluster.rb2
-rw-r--r--app/models/commit.rb4
-rw-r--r--app/models/commit_status.rb10
-rw-r--r--app/models/commit_user_mention.rb2
-rw-r--r--app/models/concerns/admin_changed_password_notifier.rb12
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage_event_model.rb4
-rw-r--r--app/models/concerns/application_setting_masked_attrs.rb14
-rw-r--r--app/models/concerns/awardable.rb2
-rw-r--r--app/models/concerns/ci/partitionable.rb1
-rw-r--r--app/models/concerns/diff_positionable_note.rb10
-rw-r--r--app/models/concerns/enums/abuse/category.rb16
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb4
-rw-r--r--app/models/concerns/has_user_type.rb20
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/issues/forbid_issue_type_column_usage.rb59
-rw-r--r--app/models/concerns/noteable.rb4
-rw-r--r--app/models/concerns/packages/downloadable.rb15
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/recoverable_by_any_email.rb39
-rw-r--r--app/models/concerns/sanitizable.rb4
-rw-r--r--app/models/concerns/spammable.rb46
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb2
-rw-r--r--app/models/deploy_key.rb4
-rw-r--r--app/models/deploy_keys_project.rb1
-rw-r--r--app/models/deployment.rb2
-rw-r--r--app/models/design_management/repository.rb2
-rw-r--r--app/models/design_user_mention.rb2
-rw-r--r--app/models/diff_discussion.rb4
-rw-r--r--app/models/diff_note_position.rb3
-rw-r--r--app/models/diff_viewer/base.rb2
-rw-r--r--app/models/discussion.rb34
-rw-r--r--app/models/environment.rb32
-rw-r--r--app/models/generic_commit_status.rb4
-rw-r--r--app/models/grafana_integration.rb4
-rw-r--r--app/models/group.rb76
-rw-r--r--app/models/group_group_link.rb3
-rw-r--r--app/models/hooks/web_hook.rb26
-rw-r--r--app/models/import_failure.rb6
-rw-r--r--app/models/integration.rb26
-rw-r--r--app/models/integrations/apple_app_store.rb31
-rw-r--r--app/models/integrations/base_chat_notification.rb6
-rw-r--r--app/models/integrations/chat_message/push_message.rb4
-rw-r--r--app/models/integrations/clickup.rb39
-rw-r--r--app/models/integrations/datadog.rb2
-rw-r--r--app/models/integrations/hangouts_chat.rb4
-rw-r--r--app/models/integrations/jira.rb125
-rw-r--r--app/models/integrations/telegram.rb105
-rw-r--r--app/models/issue.rb65
-rw-r--r--app/models/issue_link.rb3
-rw-r--r--app/models/issue_user_mention.rb2
-rw-r--r--app/models/jira_connect_installation.rb6
-rw-r--r--app/models/key.rb2
-rw-r--r--app/models/lfs_object.rb12
-rw-r--r--app/models/member.rb1
-rw-r--r--app/models/members/group_member.rb7
-rw-r--r--app/models/members/last_group_owner_assigner.rb14
-rw-r--r--app/models/merge_request.rb21
-rw-r--r--app/models/merge_request/diff_llm_summary.rb1
-rw-r--r--app/models/merge_request_diff.rb6
-rw-r--r--app/models/merge_request_user_mention.rb2
-rw-r--r--app/models/namespace.rb16
-rw-r--r--app/models/namespace/aggregation_schedule.rb2
-rw-r--r--app/models/namespace/root_storage_statistics.rb8
-rw-r--r--app/models/namespace_setting.rb17
-rw-r--r--app/models/namespaces/project_namespace.rb3
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb9
-rw-r--r--app/models/note.rb19
-rw-r--r--app/models/note_diff_file.rb2
-rw-r--r--app/models/organization.rb26
-rw-r--r--app/models/organizations/organization.rb40
-rw-r--r--app/models/packages/cleanup/policy.rb9
-rw-r--r--app/models/packages/conan/metadatum.rb6
-rw-r--r--app/models/packages/debian/file_entry.rb14
-rw-r--r--app/models/packages/go/module.rb3
-rw-r--r--app/models/packages/go/module_version.rb46
-rw-r--r--app/models/packages/npm/metadata_cache.rb1
-rw-r--r--app/models/packages/nuget/metadatum.rb19
-rw-r--r--app/models/packages/package.rb45
-rw-r--r--app/models/packages/rpm/metadatum.rb35
-rw-r--r--app/models/pages_domain.rb4
-rw-r--r--app/models/pages_domain_acme_order.rb8
-rw-r--r--app/models/personal_access_token.rb33
-rw-r--r--app/models/plan_limits.rb42
-rw-r--r--app/models/preloaders/projects/notes_preloader.rb22
-rw-r--r--app/models/project.rb245
-rw-r--r--app/models/project_feature.rb16
-rw-r--r--app/models/project_import_data.rb12
-rw-r--r--app/models/project_setting.rb7
-rw-r--r--app/models/project_statistics.rb5
-rw-r--r--app/models/project_team.rb16
-rw-r--r--app/models/projects/topic.rb13
-rw-r--r--app/models/prometheus_alert.rb2
-rw-r--r--app/models/protected_branch.rb10
-rw-r--r--app/models/release.rb38
-rw-r--r--app/models/release_highlight.rb16
-rw-r--r--app/models/releases/source.rb8
-rw-r--r--app/models/remote_mirror.rb18
-rw-r--r--app/models/repository.rb38
-rw-r--r--app/models/resource_events/abuse_report_event.rb6
-rw-r--r--app/models/resource_timebox_event.rb5
-rw-r--r--app/models/sent_notification.rb27
-rw-r--r--app/models/snippet.rb11
-rw-r--r--app/models/snippet_user_mention.rb2
-rw-r--r--app/models/suggestion.rb2
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/terraform/state.rb2
-rw-r--r--app/models/time_tracking/timelog_category.rb6
-rw-r--r--app/models/timelog.rb15
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/tree.rb16
-rw-r--r--app/models/uploads/fog.rb43
-rw-r--r--app/models/user.rb121
-rw-r--r--app/models/user_custom_attribute.rb8
-rw-r--r--app/models/user_detail.rb1
-rw-r--r--app/models/user_preference.rb4
-rw-r--r--app/models/users/callout.rb9
-rw-r--r--app/models/users/calloutable.rb4
-rw-r--r--app/models/users/group_callout.rb7
-rw-r--r--app/models/vulnerability.rb6
-rw-r--r--app/models/work_item.rb7
-rw-r--r--app/models/work_items/widgets/base.rb8
-rw-r--r--app/policies/audit_events/definition_policy.rb11
-rw-r--r--app/policies/group_policy.rb19
-rw-r--r--app/policies/organizations/organization_policy.rb9
-rw-r--r--app/policies/project_policy.rb17
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/presenters/blob_presenter.rb29
-rw-r--r--app/presenters/ci/pipeline_presenter.rb26
-rw-r--r--app/presenters/merge_request_presenter.rb4
-rw-r--r--app/presenters/ml/candidate_details_presenter.rb5
-rw-r--r--app/presenters/packages/conan/package_presenter.rb5
-rw-r--r--app/presenters/packages/nuget/packages_metadata_presenter.rb7
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb11
-rw-r--r--app/presenters/packages/nuget/search_results_presenter.rb37
-rw-r--r--app/presenters/packages/nuget/service_index_presenter.rb21
-rw-r--r--app/presenters/packages/nuget/version_helpers.rb88
-rw-r--r--app/presenters/tree_entry_presenter.rb17
-rw-r--r--app/presenters/work_item_presenter.rb4
-rw-r--r--app/serializers/admin/abuse_report_details_entity.rb21
-rw-r--r--app/serializers/environment_serializer.rb2
-rw-r--r--app/serializers/integrations/harbor_serializers/repository_entity.rb4
-rw-r--r--app/serializers/note_entity.rb2
-rw-r--r--app/serializers/profile/event_entity.rb50
-rw-r--r--app/services/achievements/destroy_user_achievement_service.rb33
-rw-r--r--app/services/admin/abuse_report_update_service.rb6
-rw-r--r--app/services/admin/plan_limits/update_service.rb38
-rw-r--r--app/services/alert_management/http_integrations/base_service.rb57
-rw-r--r--app/services/alert_management/http_integrations/create_service.rb64
-rw-r--r--app/services/alert_management/http_integrations/destroy_service.rb11
-rw-r--r--app/services/alert_management/http_integrations/update_service.rb49
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb6
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb9
-rw-r--r--app/services/boards/issues/create_service.rb2
-rw-r--r--app/services/bulk_imports/archive_extraction_service.rb4
-rw-r--r--app/services/bulk_imports/create_service.rb5
-rw-r--r--app/services/bulk_imports/file_decompression_service.rb4
-rw-r--r--app/services/bulk_imports/file_download_service.rb2
-rw-r--r--app/services/ci/cancel_pipeline_service.rb122
-rw-r--r--app/services/ci/delete_unit_tests_service.rb4
-rw-r--r--app/services/ci/destroy_pipeline_service.rb8
-rw-r--r--app/services/ci/job_artifacts/create_service.rb3
-rw-r--r--app/services/ci/job_token_scope/remove_project_service.rb2
-rw-r--r--app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb66
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb46
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb5
-rw-r--r--app/services/ci/pipelines/add_job_service.rb6
-rw-r--r--app/services/ci/reset_skipped_jobs_service.rb27
-rw-r--r--app/services/ci/runners/assign_runner_service.rb4
-rw-r--r--app/services/ci/runners/stale_managers_cleanup_service.rb17
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb12
-rw-r--r--app/services/commits/cherry_pick_service.rb13
-rw-r--r--app/services/concerns/search/filter.rb13
-rw-r--r--app/services/concerns/update_repository_storage_methods.rb8
-rw-r--r--app/services/database/mark_migration_service.rb58
-rw-r--r--app/services/environments/create_service.rb44
-rw-r--r--app/services/environments/destroy_service.rb23
-rw-r--r--app/services/environments/update_service.rb42
-rw-r--r--app/services/error_tracking/collect_error_service.rb82
-rw-r--r--app/services/feature_flags/base_service.rb4
-rw-r--r--app/services/git/branch_hooks_service.rb40
-rw-r--r--app/services/google_cloud/enable_vision_ai_service.rb19
-rw-r--r--app/services/google_cloud/generate_pipeline_service.rb16
-rw-r--r--app/services/groups/transfer_service.rb2
-rw-r--r--app/services/import_csv/base_service.rb6
-rw-r--r--app/services/incident_management/incidents/create_service.rb2
-rw-r--r--app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb2
-rw-r--r--app/services/issuable/callbacks/base.rb1
-rw-r--r--app/services/issuable/destroy_service.rb4
-rw-r--r--app/services/issuable/discussions_list_service.rb5
-rw-r--r--app/services/issuable_base_service.rb13
-rw-r--r--app/services/issues/base_service.rb4
-rw-r--r--app/services/issues/clone_service.rb6
-rw-r--r--app/services/issues/close_service.rb5
-rw-r--r--app/services/issues/create_service.rb19
-rw-r--r--app/services/issues/move_service.rb6
-rw-r--r--app/services/issues/reopen_service.rb6
-rw-r--r--app/services/issues/update_service.rb26
-rw-r--r--app/services/jira_connect_installations/update_service.rb2
-rw-r--r--app/services/merge_requests/after_create_service.rb15
-rw-r--r--app/services/merge_requests/build_service.rb2
-rw-r--r--app/services/merge_requests/close_service.rb7
-rw-r--r--app/services/merge_requests/create_service.rb1
-rw-r--r--app/services/merge_requests/merge_service.rb6
-rw-r--r--app/services/merge_requests/mergeability/logger.rb10
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb5
-rw-r--r--app/services/notes/create_service.rb29
-rw-r--r--app/services/notes/quick_actions_service.rb16
-rw-r--r--app/services/notes/update_service.rb15
-rw-r--r--app/services/object_storage/delete_stale_direct_uploads_service.rb35
-rw-r--r--app/services/packages/cleanup/execute_policy_service.rb5
-rw-r--r--app/services/packages/cleanup/update_policy_service.rb5
-rw-r--r--app/services/packages/composer/create_package_service.rb5
-rw-r--r--app/services/packages/debian/create_package_file_service.rb10
-rw-r--r--app/services/packages/debian/extract_changes_metadata_service.rb37
-rw-r--r--app/services/packages/debian/generate_distribution_key_service.rb7
-rw-r--r--app/services/packages/debian/generate_distribution_service.rb10
-rw-r--r--app/services/packages/debian/process_changes_service.rb22
-rw-r--r--app/services/packages/debian/process_package_file_service.rb81
-rw-r--r--app/services/packages/helm/process_file_service.rb27
-rw-r--r--app/services/packages/maven/metadata/base_create_xml_service.rb7
-rw-r--r--app/services/packages/maven/metadata/create_plugins_xml_service.rb39
-rw-r--r--app/services/packages/maven/metadata/create_versions_xml_service.rb42
-rw-r--r--app/services/packages/maven/metadata/sync_service.rb19
-rw-r--r--app/services/packages/ml_model/create_package_file_service.rb46
-rw-r--r--app/services/packages/ml_model/find_or_create_package_service.rb11
-rw-r--r--app/services/packages/npm/create_metadata_cache_service.rb11
-rw-r--r--app/services/packages/npm/create_package_service.rb53
-rw-r--r--app/services/packages/npm/create_tag_service.rb5
-rw-r--r--app/services/packages/nuget/metadata_extraction_service.rb31
-rw-r--r--app/services/packages/nuget/search_service.rb22
-rw-r--r--app/services/packages/nuget/sync_metadatum_service.rb47
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb46
-rw-r--r--app/services/packages/pypi/create_package_service.rb5
-rw-r--r--app/services/packages/rpm/parse_package_service.rb5
-rw-r--r--app/services/packages/rubygems/dependency_resolver_service.rb5
-rw-r--r--app/services/packages/rubygems/process_gem_service.rb18
-rw-r--r--app/services/packages/terraform_module/create_package_service.rb10
-rw-r--r--app/services/packages/update_tags_service.rb5
-rw-r--r--app/services/personal_access_tokens/create_service.rb20
-rw-r--r--app/services/personal_access_tokens/last_used_service.rb9
-rw-r--r--app/services/post_receive_service.rb9
-rw-r--r--app/services/projects/create_service.rb4
-rw-r--r--app/services/projects/import_service.rb6
-rw-r--r--app/services/projects/lfs_pointers/lfs_import_service.rb2
-rw-r--r--app/services/projects/operations/update_service.rb19
-rw-r--r--app/services/projects/participants_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb16
-rw-r--r--app/services/projects/readme_renderer_service.rb4
-rw-r--r--app/services/projects/slack_application_install_service.rb76
-rw-r--r--app/services/releases/create_service.rb8
-rw-r--r--app/services/releases/links/base_service.rb12
-rw-r--r--app/services/releases/links/params.rb29
-rw-r--r--app/services/repositories/base_service.rb2
-rw-r--r--app/services/resource_access_tokens/create_service.rb18
-rw-r--r--app/services/search/global_service.rb10
-rw-r--r--app/services/search/group_service.rb2
-rw-r--r--app/services/search/project_service.rb12
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/service_desk/custom_email_verifications/base_service.rb49
-rw-r--r--app/services/service_desk/custom_email_verifications/create_service.rb74
-rw-r--r--app/services/service_desk/custom_email_verifications/update_service.rb90
-rw-r--r--app/services/service_ping/submit_service.rb2
-rw-r--r--app/services/snippets/create_service.rb20
-rw-r--r--app/services/snippets/update_service.rb19
-rw-r--r--app/services/spam/spam_action_service.rb44
-rw-r--r--app/services/spam/spam_verdict_service.rb2
-rw-r--r--app/services/tasks_to_be_done/base_service.rb2
-rw-r--r--app/services/user_agent_detail_service.rb13
-rw-r--r--app/services/users/activate_service.rb52
-rw-r--r--app/services/users/set_namespace_commit_email_service.rb87
-rw-r--r--app/services/webauthn/destroy_service.rb30
-rw-r--r--app/services/work_items/callbacks/award_emoji.rb33
-rw-r--r--app/services/work_items/callbacks/base.rb13
-rw-r--r--app/services/work_items/create_and_link_service.rb6
-rw-r--r--app/services/work_items/create_from_task_service.rb6
-rw-r--r--app/services/work_items/create_service.rb4
-rw-r--r--app/services/work_items/delete_task_service.rb2
-rw-r--r--app/services/work_items/update_service.rb5
-rw-r--r--app/services/work_items/widgets/award_emoji_service/update_service.rb33
-rw-r--r--app/uploaders/ci/secure_file_uploader.rb4
-rw-r--r--app/uploaders/gitlab_uploader.rb4
-rw-r--r--app/uploaders/object_storage.rb30
-rw-r--r--app/validators/abstract_path_validator.rb17
-rw-r--r--app/validators/json_schemas/abuse_event_metadata.json7
-rw-r--r--app/validators/json_schemas/abuse_report_evidence.json107
-rw-r--r--app/validators/json_schemas/ci_job_annotation_data.json19
-rw-r--r--app/validators/json_schemas/ci_job_external_link_data.json13
-rw-r--r--app/validators/json_schemas/default_branch_protection_defaults.json76
-rw-r--r--app/validators/json_schemas/plan_limits_history.json115
-rw-r--r--app/validators/json_schemas/position.json6
-rw-r--r--app/validators/key_restriction_validator.rb2
-rw-r--r--app/validators/organizations/path_validator.rb15
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml8
-rw-r--r--app/views/admin/application_settings/_ai_access.html.haml32
-rw-r--r--app/views/admin/application_settings/_diagramsnet.html.haml25
-rw-r--r--app/views/admin/application_settings/_diff_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_pages.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance.html.haml6
-rw-r--r--app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml2
-rw-r--r--app/views/admin/application_settings/_signin.html.haml2
-rw-r--r--app/views/admin/application_settings/_user_restrictions.html.haml1
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/application_settings/service_usage_data.html.haml2
-rw-r--r--app/views/admin/jobs/index.html.haml6
-rw-r--r--app/views/admin/labels/index.html.haml2
-rw-r--r--app/views/admin/projects/index.html.haml6
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml2
-rw-r--r--app/views/admin/sessions/_two_factor_otp.html.haml2
-rw-r--r--app/views/admin/sessions/new.html.haml2
-rw-r--r--app/views/admin/sessions/two_factor.html.haml17
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml2
-rw-r--r--app/views/admin/spam_logs/index.html.haml1
-rw-r--r--app/views/admin/topics/_form.html.haml17
-rw-r--r--app/views/admin/topics/_topic.html.haml2
-rw-r--r--app/views/admin/users/_users.html.haml4
-rw-r--r--app/views/ci/group_variables/_index.html.haml19
-rw-r--r--app/views/ci/variables/_content.html.haml10
-rw-r--r--app/views/ci/variables/_index.html.haml11
-rw-r--r--app/views/clusters/clusters/_banner.html.haml4
-rw-r--r--app/views/clusters/clusters/_deprecation_alert.html.haml2
-rw-r--r--app/views/clusters/clusters/_details_tab.html.haml2
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml4
-rw-r--r--app/views/clusters/clusters/_health.html.haml8
-rw-r--r--app/views/clusters/clusters/_health_tab.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/_projects_head.html.haml6
-rw-r--r--app/views/dashboard/groups/_groups.html.haml2
-rw-r--r--app/views/dashboard/merge_requests.html.haml16
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml2
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml4
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/passwords/edit.html.haml5
-rw-r--r--app/views/devise/passwords/new.html.haml19
-rw-r--r--app/views/devise/registrations/new.html.haml3
-rw-r--r--app/views/devise/sessions/_new_base.html.haml34
-rw-r--r--app/views/devise/sessions/_new_base_user_login_label.html.haml1
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml29
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml25
-rw-r--r--app/views/devise/sessions/email_verification.haml4
-rw-r--r--app/views/devise/sessions/two_factor.html.haml35
-rw-r--r--app/views/devise/shared/_error_messages.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml8
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml5
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml2
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers_top.haml3
-rw-r--r--app/views/explore/groups/_groups.html.haml2
-rw-r--r--app/views/explore/projects/_filter.html.haml2
-rw-r--r--app/views/explore/projects/_head.html.haml2
-rw-r--r--app/views/groups/labels/index.html.haml2
-rw-r--r--app/views/groups/settings/_advanced.html.haml4
-rw-r--r--app/views/groups/settings/_lfs.html.haml4
-rw-r--r--app/views/groups/settings/_permissions.html.haml3
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/ide/_show.html.haml3
-rw-r--r--app/views/import/shared/_errors.html.haml2
-rw-r--r--app/views/layouts/_header_search.html.haml2
-rw-r--r--app/views/layouts/_loading_hints.html.haml6
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/devise.html.haml16
-rw-r--r--app/views/layouts/fullscreen.html.haml2
-rw-r--r--app/views/layouts/group.html.haml3
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml5
-rw-r--r--app/views/layouts/header/_default.html.haml2
-rw-r--r--app/views/layouts/header/_registration_enabled_callout.html.haml4
-rw-r--r--app/views/layouts/project.html.haml3
-rw-r--r--app/views/layouts/terms.html.haml4
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.html.haml4
-rw-r--r--app/views/notify/merge_when_pipeline_succeeds_email.text.haml2
-rw-r--r--app/views/organizations/organizations/directory.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml71
-rw-r--r--app/views/profiles/active_sessions/index.html.haml2
-rw-r--r--app/views/profiles/chat_names/new.html.haml6
-rw-r--r--app/views/profiles/keys/_key_details.html.haml8
-rw-r--r--app/views/profiles/notifications/show.html.haml2
-rw-r--r--app/views/profiles/preferences/show.html.haml5
-rw-r--r--app/views/profiles/show.html.haml377
-rw-r--r--app/views/profiles/slacks/edit.html.haml6
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml6
-rw-r--r--app/views/projects/_files.html.haml6
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml6
-rw-r--r--app/views/projects/_new_project_fields.html.haml2
-rw-r--r--app/views/projects/_service_desk_settings.html.haml1
-rw-r--r--app/views/projects/blame/_page.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml6
-rw-r--r--app/views/projects/blob/_editor.html.haml7
-rw-r--r--app/views/projects/blob/_render_error.html.haml2
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_contributing.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml109
-rw-r--r--app/views/projects/branches/_commit.html.haml6
-rw-r--r--app/views/projects/branches/_delete_branch_modal_button.html.haml18
-rw-r--r--app/views/projects/branches/index.html.haml6
-rw-r--r--app/views/projects/buttons/_download.html.haml3
-rw-r--r--app/views/projects/commit/_commit_box.html.haml5
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml2
-rw-r--r--app/views/projects/commits/_commits.html.haml12
-rw-r--r--app/views/projects/diffs/_file.html.haml7
-rw-r--r--app/views/projects/diffs/_text_file.html.haml53
-rw-r--r--app/views/projects/edit.html.haml6
-rw-r--r--app/views/projects/empty.html.haml10
-rw-r--r--app/views/projects/environments/edit.html.haml3
-rw-r--r--app/views/projects/environments/new.html.haml2
-rw-r--r--app/views/projects/issues/_issues.html.haml2
-rw-r--r--app/views/projects/issues/_work_item_links.html.haml1
-rw-r--r--app/views/projects/issues/service_desk/_issue.html.haml (renamed from app/views/projects/issues/_issue.html.haml)3
-rw-r--r--app/views/projects/issues/service_desk/_issue_estimate.html.haml (renamed from app/views/projects/issues/_issue_estimate.html.haml)0
-rw-r--r--app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml4
-rw-r--r--app/views/projects/issues/service_desk/_service_desk_info_content.html.haml2
-rw-r--r--app/views/projects/issues/service_desk/icons/_service_desk_callout.svg (renamed from app/views/shared/empty_states/icons/_service_desk_callout.svg)0
-rw-r--r--app/views/projects/issues/service_desk/icons/_service_desk_empty_state.svg (renamed from app/views/shared/empty_states/icons/_service_desk_empty_state.svg)0
-rw-r--r--app/views/projects/issues/service_desk/icons/_service_desk_setup.svg (renamed from app/views/shared/empty_states/icons/_service_desk_setup.svg)0
-rw-r--r--app/views/projects/labels/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml68
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml1
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/_page.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml10
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml12
-rw-r--r--app/views/projects/mirrors/_branch_filter.html.haml15
-rw-r--r--app/views/projects/mirrors/_mirror_repos_push.html.haml15
-rw-r--r--app/views/projects/network/show.html.haml9
-rw-r--r--app/views/projects/network/show.json.erb2
-rw-r--r--app/views/projects/pages/_waiting.html.haml2
-rw-r--r--app/views/projects/pages_domains/show.html.haml9
-rw-r--r--app/views/projects/pipelines/_info.html.haml2
-rw-r--r--app/views/projects/pipelines/show.html.haml7
-rw-r--r--app/views/projects/project_members/index.html.haml5
-rw-r--r--app/views/projects/readme_templates/default.md.tt2
-rw-r--r--app/views/projects/runners/_project_runners.html.haml3
-rw-r--r--app/views/projects/settings/access_tokens/_form.html.haml14
-rw-r--r--app/views/projects/settings/access_tokens/index.html.haml14
-rw-r--r--app/views/projects/settings/operations/_grafana_integration.html.haml2
-rw-r--r--app/views/projects/settings/operations/_metrics_dashboard.html.haml5
-rw-r--r--app/views/projects/settings/operations/show.html.haml7
-rw-r--r--app/views/projects/settings/repository/show.html.haml3
-rw-r--r--app/views/projects/settings/slacks/edit.html.haml20
-rw-r--r--app/views/projects/tags/new.html.haml6
-rw-r--r--app/views/projects/tags/show.html.haml4
-rw-r--r--app/views/protected_branches/_create_protected_branch.html.haml4
-rw-r--r--app/views/protected_branches/shared/_create_protected_branch.html.haml8
-rw-r--r--app/views/registrations/welcome/show.html.haml3
-rw-r--r--app/views/search/_results.html.haml2
-rw-r--r--app/views/search/_results_list.html.haml3
-rw-r--r--app/views/shared/_alert_info.html.haml2
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml2
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml4
-rw-r--r--app/views/shared/_broadcast_message.html.haml28
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml2
-rw-r--r--app/views/shared/_custom_attributes.html.haml4
-rw-r--r--app/views/shared/_event_filter.html.haml6
-rw-r--r--app/views/shared/_ide_root.html.haml9
-rw-r--r--app/views/shared/_import_form.html.haml2
-rw-r--r--app/views/shared/_model_version_conflict.html.haml2
-rw-r--r--app/views/shared/_new_merge_request_checkbox.html.haml15
-rw-r--r--app/views/shared/_new_nav_announcement.html.haml33
-rw-r--r--app/views/shared/_no_password.html.haml4
-rw-r--r--app/views/shared/_no_ssh.html.haml4
-rw-r--r--app/views/shared/_outdated_browser.html.haml2
-rw-r--r--app/views/shared/_project_limit.html.haml4
-rw-r--r--app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml2
-rw-r--r--app/views/shared/_service_ping_consent.html.haml4
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml4
-rw-r--r--app/views/shared/_web_ide_button.html.haml2
-rw-r--r--app/views/shared/access_tokens/_form.html.haml16
-rw-r--r--app/views/shared/admin/_admin_note.html.haml4
-rw-r--r--app/views/shared/empty_states/_issues.html.haml4
-rw-r--r--app/views/shared/errors/_gitaly_unavailable.html.haml2
-rw-r--r--app/views/shared/file_hooks/_index.html.haml6
-rw-r--r--app/views/shared/hook_logs/_content.html.haml5
-rw-r--r--app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml4
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_help.html.haml10
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml4
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml32
-rw-r--r--app/views/shared/integrations/gitlab_slack_application/_top.html.haml5
-rw-r--r--app/views/shared/integrations/prometheus/_custom_metrics.html.haml4
-rw-r--r--app/views/shared/integrations/prometheus/_metrics.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml8
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml8
-rw-r--r--app/views/shared/issuable/form/_title.html.haml4
-rw-r--r--app/views/shared/issue_type/_details_content.html.haml2
-rw-r--r--app/views/shared/members/_requests.html.haml4
-rw-r--r--app/views/shared/milestones/_description.html.haml4
-rw-r--r--app/views/shared/milestones/_issuables.html.haml4
-rw-r--r--app/views/shared/milestones/_milestone_complete_alert.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml6
-rw-r--r--app/views/shared/notes/_edit_form.html.haml1
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml18
-rw-r--r--app/views/shared/projects/_topics.html.haml4
-rw-r--r--app/views/shared/promotions/_promote_servicedesk.html.haml2
-rw-r--r--app/views/shared/runners/_form.html.haml4
-rw-r--r--app/views/shared/runners/_runner_details.html.haml2
-rw-r--r--app/views/shared/runners/_runner_type_alert.html.haml4
-rw-r--r--app/views/shared/runners/_runner_type_badge.html.haml14
-rw-r--r--app/views/shared/topics/_topic.html.haml4
-rw-r--r--app/views/shared/users/_user.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook_errors.html.haml6
-rw-r--r--app/views/shared/web_hooks/_index.html.haml4
-rw-r--r--app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml4
-rw-r--r--app/views/shared/wikis/empty.html.haml2
-rw-r--r--app/views/users/_profile_basic_info.html.haml2
-rw-r--r--app/views/users/show.html.haml6
-rw-r--r--app/workers/all_queues.yml81
-rw-r--r--app/workers/ci/cancel_pipeline_worker.rb10
-rw-r--r--app/workers/ci/update_locked_unknown_artifacts_worker.rb2
-rw-r--r--app/workers/clusters/integrations/check_prometheus_health_worker.rb10
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb8
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb8
-rw-r--r--app/workers/concerns/worker_attributes.rb17
-rw-r--r--app/workers/container_registry/record_data_repair_detail_worker.rb7
-rw-r--r--app/workers/database/batched_background_migration/execution_worker.rb1
-rw-r--r--app/workers/database/batched_background_migration/single_database_worker.rb27
-rw-r--r--app/workers/database/monitor_locked_tables_worker.rb52
-rw-r--r--app/workers/disallow_two_factor_for_group_worker.rb2
-rw-r--r--app/workers/disallow_two_factor_for_subgroups_worker.rb2
-rw-r--r--app/workers/file_hook_worker.rb2
-rw-r--r--app/workers/gitlab/github_gists_import/import_gist_worker.rb75
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_review_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_release_attachments_worker.rb23
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb17
-rw-r--r--app/workers/group_destroy_worker.rb2
-rw-r--r--app/workers/incident_management/close_incident_worker.rb2
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb2
-rw-r--r--app/workers/merge_requests/mergeability_check_batch_worker.rb34
-rw-r--r--app/workers/metrics/dashboard/prune_old_annotations_worker.rb7
-rw-r--r--app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb6
-rw-r--r--app/workers/metrics/dashboard/sync_dashboards_worker.rb9
-rw-r--r--app/workers/new_issue_worker.rb10
-rw-r--r--app/workers/object_storage/delete_stale_direct_uploads_worker.rb27
-rw-r--r--app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb6
-rw-r--r--app/workers/packages/debian/process_package_file_worker.rb2
-rw-r--r--app/workers/packages/npm/create_metadata_cache_worker.rb29
-rw-r--r--app/workers/post_receive.rb15
-rw-r--r--app/workers/projects/record_target_platforms_worker.rb2
-rw-r--r--app/workers/web_hook_worker.rb2
-rw-r--r--app/workers/web_hooks/log_destroy_worker.rb2
-rw-r--r--app/workers/web_hooks/log_execution_worker.rb2
1487 files changed, 22750 insertions, 10597 deletions
diff --git a/app/assets/images/auth_buttons/shibboleth_64.png b/app/assets/images/auth_buttons/shibboleth_64.png
new file mode 100644
index 00000000000..d4c752f9400
--- /dev/null
+++ b/app/assets/images/auth_buttons/shibboleth_64.png
Binary files differ
diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
index 4a7c12e5e51..266950e2769 100644
--- a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
+++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
@@ -60,11 +60,11 @@ export default {
};
},
computed: {
- drawerOffsetTop() {
+ getDrawerHeaderHeight() {
// avoid calculating this in advance because it causes layout thrashing
// https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396
if (!this.showDrawer) return '0';
- return getContentWrapperHeight('.content-wrapper');
+ return getContentWrapperHeight();
},
},
mounted() {
@@ -81,7 +81,7 @@ export default {
</script>
<template>
<gl-drawer
- :header-height="drawerOffsetTop"
+ :header-height="getDrawerHeaderHeight"
:z-index="300"
:open="showDrawer && mounted"
@close="closeDrawer"
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
index a58b6e62254..eb5d1d39142 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue
@@ -38,7 +38,7 @@ export default {
:class="[
{
'gl-ml-5': !contextCommitsEmpty,
- 'gl-mt-5': !commitsEmpty && contextCommitsEmpty,
+ 'gl-mt-1': !commitsEmpty && contextCommitsEmpty,
},
]"
:variant="commitsEmpty ? 'confirm' : 'default'"
diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
index 9355c1c788f..1490d7e64f5 100644
--- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
+++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
@@ -1,12 +1,20 @@
<script>
+import { GlAlert } from '@gitlab/ui';
import ReportHeader from './report_header.vue';
import UserDetails from './user_details.vue';
import ReportedContent from './reported_content.vue';
import HistoryItems from './history_items.vue';
+const alertDefaults = {
+ visible: false,
+ variant: '',
+ message: '',
+};
+
export default {
name: 'AbuseReportApp',
components: {
+ GlAlert,
ReportHeader,
UserDetails,
ReportedContent,
@@ -18,15 +26,34 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ alert: { ...alertDefaults },
+ };
+ },
+ methods: {
+ showAlert(variant, message) {
+ this.alert.visible = true;
+ this.alert.variant = variant;
+ this.alert.message = message;
+ },
+ closeAlert() {
+ this.alert = { ...alertDefaults };
+ },
+ },
};
</script>
<template>
<section>
+ <gl-alert v-if="alert.visible" :variant="alert.variant" class="gl-mt-4" @dismiss="closeAlert">{{
+ alert.message
+ }}</gl-alert>
<report-header
v-if="abuseReport.user"
:user="abuseReport.user"
- :actions="abuseReport.actions"
+ :report="abuseReport.report"
+ @showAlert="showAlert"
/>
<user-details v-if="abuseReport.user" :user="abuseReport.user" />
<reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" />
diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue
new file mode 100644
index 00000000000..57d5d46ceb4
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue
@@ -0,0 +1,206 @@
+<script>
+import {
+ GlForm,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormCheckbox,
+ GlFormInput,
+ GlButton,
+ GlDrawer,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import {
+ ACTIONS_I18N,
+ NO_ACTION,
+ USER_ACTION_OPTIONS,
+ REASON_OPTIONS,
+ STATUS_OPEN,
+ SUCCESS_ALERT,
+ FAILED_ALERT,
+ ERROR_MESSAGE,
+} from '../constants';
+
+const formDefaults = {
+ user_action: '',
+ close: false,
+ comment: '',
+ reason: '',
+};
+
+export default {
+ name: 'ReportActions',
+ components: {
+ GlForm,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormCheckbox,
+ GlFormInput,
+ GlButton,
+ GlDrawer,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showActionsDrawer: false,
+ validationState: {
+ reason: true,
+ action: true,
+ },
+ form: { ...formDefaults },
+ };
+ },
+ computed: {
+ getDrawerHeaderHeight() {
+ if (!this.showActionsDrawer || gon.use_new_navigation) return '0';
+ return getContentWrapperHeight();
+ },
+ isFormValid() {
+ return Object.values(this.validationState).every(Boolean);
+ },
+ isOpen() {
+ return this.report.status === STATUS_OPEN;
+ },
+ isNotCurrentUser() {
+ return this.user.username !== gon.current_username;
+ },
+ userActionOptions() {
+ return this.isNotCurrentUser ? USER_ACTION_OPTIONS : [NO_ACTION];
+ },
+ },
+ methods: {
+ toggleActionsDrawer() {
+ this.showActionsDrawer = !this.showActionsDrawer;
+ },
+ validateReason() {
+ this.validationState.reason = Boolean(this.form.reason?.length);
+ },
+ validateAction() {
+ this.validationState.action = Boolean(this.form.user_action?.length) || this.form.close;
+ },
+ submitForm() {
+ this.triggerValidation();
+
+ if (!this.isFormValid) {
+ return;
+ }
+
+ axios
+ .put(this.report.updatePath, this.form)
+ .then(this.handleResponse)
+ .catch(this.handleError);
+ },
+ handleResponse({ data }) {
+ this.toggleActionsDrawer();
+ this.$emit('showAlert', SUCCESS_ALERT, data.message);
+ if (this.form.close) {
+ this.$emit('closeReport');
+ }
+ this.resetForm();
+ },
+ handleError({ response }) {
+ this.toggleActionsDrawer();
+ const message = response?.data?.message || ERROR_MESSAGE;
+ this.$emit('showAlert', FAILED_ALERT, message);
+ },
+ resetForm() {
+ this.form = { ...formDefaults };
+ },
+ triggerValidation() {
+ this.validateReason();
+ this.validateAction();
+ },
+ },
+ i18n: ACTIONS_I18N,
+ reasonOptions: REASON_OPTIONS,
+ DRAWER_Z_INDEX,
+};
+</script>
+
+<template>
+ <div>
+ <gl-button class="gl-w-full" data-testid="actions-button" @click="toggleActionsDrawer">
+ {{ $options.i18n.actions }}
+ </gl-button>
+ <gl-drawer
+ :open="showActionsDrawer"
+ :header-height="getDrawerHeaderHeight"
+ :z-index="$options.DRAWER_Z_INDEX"
+ @close="toggleActionsDrawer"
+ >
+ <template #title>
+ <div class="gl-font-weight-bold gl-font-size-h2">{{ $options.i18n.actions }}</div>
+ </template>
+ <template #default>
+ <gl-form @submit.prevent="submitForm">
+ <gl-form-group
+ data-testid="action"
+ :label="$options.i18n.action"
+ label-for="action"
+ :invalid-feedback="$options.i18n.requiredFieldFeedback"
+ :state="validationState.action"
+ >
+ <gl-form-select
+ id="action"
+ v-model="form.user_action"
+ data-testid="action-select"
+ :options="userActionOptions"
+ :state="validationState.action"
+ @change="validateAction"
+ />
+ </gl-form-group>
+ <gl-form-group v-if="isOpen">
+ <gl-form-checkbox v-model="form.close" data-testid="close" @change="validateAction">
+ {{ $options.i18n.closeReport }}
+ </gl-form-checkbox>
+ </gl-form-group>
+ <gl-form-group
+ data-testid="reason"
+ :label="$options.i18n.reason"
+ label-for="reason"
+ :invalid-feedback="$options.i18n.requiredFieldFeedback"
+ :state="validationState.reason"
+ >
+ <gl-form-select
+ id="reason"
+ v-model="form.reason"
+ data-testid="reason-select"
+ :options="$options.reasonOptions"
+ :state="validationState.reason"
+ @change="validateReason"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :optional="true"
+ optional-text="(optional)"
+ :label="$options.i18n.comment"
+ label-for="comment"
+ >
+ <gl-form-input id="comment" v-model="form.comment" data-testid="comment" />
+ </gl-form-group>
+ </gl-form>
+ </template>
+ <template #footer>
+ <gl-button
+ variant="confirm"
+ block
+ :disabled="!isFormValid"
+ data-testid="submit-button"
+ @click="submitForm"
+ >
+ {{ $options.i18n.confirm }}
+ </gl-button>
+ </template>
+ </gl-drawer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
index 54586041354..624dcd47650 100644
--- a/app/assets/javascripts/admin/abuse_report/components/report_header.vue
+++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
@@ -1,26 +1,55 @@
<script>
-import { GlAvatar, GlButton, GlLink } from '@gitlab/ui';
-import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
-import { REPORT_HEADER_I18N } from '../constants';
+import { GlBadge, GlIcon, GlAvatar, GlButton, GlLink } from '@gitlab/ui';
+import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '../constants';
+import ReportActions from './report_actions.vue';
export default {
name: 'ReportHeader',
components: {
+ GlBadge,
+ GlIcon,
GlAvatar,
GlButton,
GlLink,
- AbuseReportActions,
+ ReportActions,
},
props: {
user: {
type: Object,
required: true,
},
- actions: {
+ report: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ state: this.report.status,
+ };
+ },
+ computed: {
+ isOpen() {
+ return this.state === STATUS_OPEN;
+ },
+ badgeClass() {
+ return this.isOpen ? 'issuable-status-badge-open' : 'issuable-status-badge-closed';
+ },
+ badgeVariant() {
+ return this.isOpen ? 'success' : 'info';
+ },
+ badgeText() {
+ return REPORT_HEADER_I18N[this.state];
+ },
+ badgeIcon() {
+ return this.isOpen ? 'issues' : 'issue-closed';
+ },
+ },
+ methods: {
+ closeReport() {
+ this.state = STATUS_CLOSED;
+ },
+ },
i18n: REPORT_HEADER_I18N,
};
</script>
@@ -30,17 +59,34 @@ export default {
class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
>
<div class="gl-display-flex gl-align-items-center">
+ <gl-badge
+ class="issuable-status-badge gl-mr-3"
+ :class="badgeClass"
+ :variant="badgeVariant"
+ :aria-label="badgeText"
+ >
+ <gl-icon :name="badgeIcon" class="gl-badge-icon" />
+ <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span>
+ </gl-badge>
<gl-avatar :size="48" :src="user.avatarUrl" />
<h1 class="gl-font-size-h-display gl-my-0 gl-ml-3">
{{ user.name }}
</h1>
<gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link>
</div>
- <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0">
- <gl-button :href="user.adminPath" class="flex-grow-1">
+ <nav
+ class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-xs-flex-direction-column"
+ >
+ <gl-button :href="user.adminPath">
{{ $options.i18n.adminProfile }}
</gl-button>
- <abuse-report-actions :report="actions" class="gl-sm-ml-3" />
+ <report-actions
+ :user="user"
+ :report="report"
+ class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0"
+ @closeReport="closeReport"
+ v-on="$listeners"
+ />
</nav>
</header>
</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
index b5ffba26360..f4f0fcac58f 100644
--- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
+++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
@@ -1,10 +1,9 @@
<script>
-import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui';
+import { GlButton, GlModal, GlCard, GlLink, GlAvatar, GlTruncateText } from '@gitlab/ui';
import { __ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
import { REPORTED_CONTENT_I18N } from '../constants';
export default {
@@ -15,8 +14,8 @@ export default {
GlCard,
GlLink,
GlAvatar,
+ GlTruncateText,
TimeAgoTooltip,
- TruncatedText,
},
modalId: 'abuse-report-screenshot-modal',
directives: {
@@ -109,13 +108,13 @@ export default {
footer-class="gl-bg-white js-test-card-footer"
>
<template v-if="report.content" #header>
- <truncated-text>
+ <gl-truncate-text>
<div
ref="gfmContent"
v-safe-html:[$options.safeHtmlConfig]="report.content"
class="md"
></div>
- </truncated-text>
+ </gl-truncate-text>
</template>
{{ $options.i18n.reportedBy }}
<template #footer>
diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js
index a59e10b5d4a..b290581598a 100644
--- a/app/assets/javascripts/admin/abuse_report/constants.js
+++ b/app/assets/javascripts/admin/abuse_report/constants.js
@@ -1,9 +1,57 @@
-import { s__, n__ } from '~/locale';
+import { s__, n__, __ } from '~/locale';
+
+export const STATUS_OPEN = 'open';
+export const STATUS_CLOSED = 'closed';
+
+export const SUCCESS_ALERT = 'success';
+export const FAILED_ALERT = 'danger';
+
+export const ERROR_MESSAGE = __('Something went wrong. Please try again.');
export const REPORT_HEADER_I18N = {
adminProfile: s__('AbuseReport|Admin profile'),
+ open: __('Open'),
+ closed: __('Closed'),
+};
+
+export const ACTIONS_I18N = {
+ actions: s__('AbuseReport|Actions'),
+ confirm: s__('AbuseReport|Confirm'),
+ action: s__('AbuseReport|Action'),
+ reason: s__('AbuseReport|Reason'),
+ comment: s__('AbuseReport|Comment'),
+ closeReport: s__('AbuseReport|Close report'),
+ requiredFieldFeedback: __('This field is required.'),
};
+export const NO_ACTION = { value: '', text: s__('AbuseReport|No action') };
+
+export const USER_ACTION_OPTIONS = [
+ NO_ACTION,
+ { value: 'block_user', text: s__('AbuseReport|Block user') },
+ { value: 'ban_user', text: s__('AbuseReport|Ban user') },
+ { value: 'delete_user', text: s__('AbuseReport|Delete user') },
+];
+
+export const REASON_OPTIONS = [
+ { value: '', text: '' },
+ { value: 'spam', text: s__('AbuseReport|Confirmed spam') },
+ { value: 'offensive', text: s__('AbuseReport|Confirmed offensive or abusive behavior') },
+ { value: 'phishing', text: s__('AbuseReport|Confirmed phishing') },
+ { value: 'crypto', text: s__('AbuseReport|Confirmed crypto mining') },
+ {
+ value: 'credentials',
+ text: s__('AbuseReport|Confirmed posting of personal information or credentials'),
+ },
+ {
+ value: 'copyright',
+ text: s__('AbuseReport|Confirmed violation of a copyright or a trademark'),
+ },
+ { value: 'malware', text: s__('AbuseReport|Confirmed posting of malware') },
+ { value: 'other', text: s__('AbuseReport|Something else') },
+ { value: 'unconfirmed', text: s__('AbuseReport|Abuse unconfirmed') },
+];
+
export const USER_DETAILS_I18N = {
createdAt: s__('AbuseReport|Member since'),
email: s__('AbuseReport|Email'),
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
deleted file mode 100644
index 5d42caa75ab..00000000000
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
+++ /dev/null
@@ -1,177 +0,0 @@
-<script>
-import { GlDisclosureDropdown, GlModal } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { __, sprintf } from '~/locale';
-import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { ACTIONS_I18N } from '../constants';
-
-const modalActionButtonAttributes = {
- block: {
- text: __('OK'),
- attributes: {
- variant: 'confirm',
- },
- },
- removeUserAndReport: {
- text: __('OK'),
- attributes: {
- variant: 'danger',
- },
- },
- secondary: {
- text: __('Cancel'),
- attributes: {
- variant: 'default',
- },
- },
-};
-const BLOCK_ACTION = 'block';
-const REMOVE_USER_AND_REPORT_ACTION = 'removeUserAndReport';
-
-export default {
- name: 'AbuseReportActions',
- components: {
- GlDisclosureDropdown,
- GlModal,
- },
- modalId: 'abuse-report-row-action-confirm-modal',
- modalActionButtonAttributes,
- i18n: ACTIONS_I18N,
- props: {
- report: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- userBlocked: this.report.userBlocked,
- confirmModalShown: false,
- actionToConfirm: 'block',
- };
- },
- computed: {
- blockUserButtonText() {
- const { alreadyBlocked, blockUser } = this.$options.i18n;
-
- return this.userBlocked ? alreadyBlocked : blockUser;
- },
- removeUserAndReportConfirmText() {
- return sprintf(this.$options.i18n.removeUserAndReportConfirm, {
- user: this.report.reportedUser.name,
- });
- },
- modalData() {
- return {
- [BLOCK_ACTION]: {
- action: this.blockUser,
- confirmText: this.$options.i18n.blockUserConfirm,
- },
- [REMOVE_USER_AND_REPORT_ACTION]: {
- action: this.removeUserAndReport,
- confirmText: this.removeUserAndReportConfirmText,
- },
- };
- },
- reportActionsDropdownItems() {
- return [
- {
- text: this.$options.i18n.removeUserAndReport,
- action: () => {
- this.showConfirmModal(REMOVE_USER_AND_REPORT_ACTION);
- },
- extraAttrs: { class: 'gl-text-red-500!' },
- },
- {
- text: this.blockUserButtonText,
- action: () => {
- this.showConfirmModal(BLOCK_ACTION);
- },
- extraAttrs: {
- disabled: this.userBlocked,
- 'data-testid': 'block-user-button',
- },
- },
- {
- text: this.$options.i18n.removeReport,
- action: () => {
- this.removeReport();
- },
- },
- ];
- },
- },
- methods: {
- showConfirmModal(action) {
- this.confirmModalShown = true;
- this.actionToConfirm = action;
- },
- blockUser() {
- axios
- .put(this.report.blockUserPath)
- .then(this.handleBlockUserResponse)
- .catch(this.handleError);
- },
- removeUserAndReport() {
- axios
- .delete(this.report.removeUserAndReportPath)
- .then(this.handleRemoveReportResponse)
- .catch(this.handleError);
- },
- removeReport() {
- axios
- .delete(this.report.removeReportPath)
- .then(this.handleRemoveReportResponse)
- .catch(this.handleError);
- },
- handleRemoveReportResponse() {
- // eslint-disable-next-line import/no-deprecated
- if (this.report.redirectPath) redirectTo(this.report.redirectPath);
- else refreshCurrentPage();
- },
- handleBlockUserResponse({ data }) {
- const message = data?.error || data?.notice;
- const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {};
-
- if (message) {
- createAlert({ message, ...alertOptions });
- }
-
- if (!data?.error) {
- this.userBlocked = true;
- }
- },
- handleError(error) {
- createAlert({
- message: __('Something went wrong. Please try again.'),
- captureError: true,
- error,
- });
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-disclosure-dropdown
- :toggle-text="$options.i18n.actionsToggleText"
- text-sr-only
- icon="ellipsis_v"
- category="tertiary"
- no-caret
- placement="right"
- :items="reportActionsDropdownItems"
- />
- <gl-modal
- v-model="confirmModalShown"
- :modal-id="$options.modalId"
- :title="modalData[actionToConfirm].confirmText"
- size="sm"
- :action-primary="$options.modalActionButtonAttributes[actionToConfirm]"
- :action-secondary="$options.modalActionButtonAttributes.secondary"
- @primary="modalData[actionToConfirm].action"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index 7dd60e9da95..9458aea299e 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -78,13 +78,3 @@ export const FILTERED_SEARCH_TOKENS = [
FILTERED_SEARCH_TOKEN_REPORTER,
FILTERED_SEARCH_TOKEN_STATUS,
];
-
-export const ACTIONS_I18N = {
- blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'),
- blockUser: __('Block user'),
- alreadyBlocked: __('Already blocked'),
- removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'),
- removeUserAndReport: __('Remove user & report'),
- removeReport: __('Remove report'),
- actionsToggleText: __('Actions'),
-};
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
index 022f5df9c96..427e6c14327 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -12,7 +12,7 @@ import {
GlFormTextarea,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import { createAlert, VARIANT_DANGER } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
@@ -71,6 +71,11 @@ export default {
addError: s__('BroadcastMessages|There was an error adding broadcast message.'),
update: s__('BroadcastMessages|Update broadcast message'),
updateError: s__('BroadcastMessages|There was an error updating broadcast message.'),
+ cancel: __('Cancel'),
+ showInCli: s__('BroadcastMessages|Git remote responses'),
+ showInCliDescription: s__(
+ 'BroadcastMessages|Show the broadcast message in a command-line interface as a Git remote response',
+ ),
},
messageThemes: THEMES,
messageTypes: TYPES,
@@ -96,6 +101,7 @@ export default {
startsAt: new Date(this.broadcastMessage.startsAt.getTime()),
endsAt: new Date(this.broadcastMessage.endsAt.getTime()),
renderedMessage: '',
+ showInCli: this.broadcastMessage.showInCli,
};
},
computed: {
@@ -126,6 +132,7 @@ export default {
target_access_levels: this.targetAccessLevels,
starts_at: this.startsAt.toISOString(),
ends_at: this.endsAt.toISOString(),
+ show_in_cli: this.showInCli,
});
},
},
@@ -225,6 +232,17 @@ export default {
<span>{{ $options.i18n.dismissableDescription }}</span>
</gl-form-checkbox>
</gl-form-group>
+
+ <gl-form-group :label="$options.i18n.showInCli" label-for="show-in-cli-checkbox">
+ <gl-form-checkbox
+ id="show-in-cli-checkbox"
+ v-model="showInCli"
+ class="gl-mt-3"
+ data-testid="show-in-cli-checkbox"
+ >
+ <span>{{ $options.i18n.showInCliDescription }}</span>
+ </gl-form-checkbox>
+ </gl-form-group>
</template>
<gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes">
@@ -256,9 +274,13 @@ export default {
:loading="loading"
:disabled="messageBlank"
data-testid="submit-button"
+ class="gl-mr-2"
>
{{ isAddForm ? $options.i18n.add : $options.i18n.update }}
</gl-button>
+ <gl-button v-if="!isAddForm" :href="messagesPath" data-testid="cancel-button">
+ {{ $options.i18n.cancel }}
+ </gl-button>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js
index 9f64b2dcaa0..ed137181a48 100644
--- a/app/assets/javascripts/admin/broadcast_messages/constants.js
+++ b/app/assets/javascripts/admin/broadcast_messages/constants.js
@@ -30,4 +30,5 @@ export const NEW_BROADCAST_MESSAGE = {
targetAccessLevels: [],
startsAt: new Date(),
endsAt: new Date(),
+ showInCli: true,
};
diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js
index 91dae949d45..33b3b028c58 100644
--- a/app/assets/javascripts/admin/broadcast_messages/edit.js
+++ b/app/assets/javascripts/admin/broadcast_messages/edit.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import MessageForm from './components/message_form.vue';
export default () => {
@@ -16,6 +17,7 @@ export default () => {
targetPath,
startsAt,
endsAt,
+ showInCli,
} = el.dataset;
return new Vue({
@@ -34,11 +36,12 @@ export default () => {
message,
broadcastType,
theme,
- dismissable: dismissable === 'true',
+ dismissable: parseBoolean(dismissable),
targetAccessLevels: JSON.parse(targetAccessLevels),
targetPath,
startsAt: new Date(startsAt),
endsAt: new Date(endsAt),
+ showInCli: parseBoolean(showInCli),
},
},
});
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 1a586bd1e91..bc4df04cb30 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -159,8 +159,10 @@ export default {
</div>
<div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle">
- <div class="right-arrow">
- <i class="right-arrow-head"></i>
+ <div class="right-arrow gl-relative gl-w-full gl-bg-gray-400">
+ <i
+ class="right-arrow-head gl-absolute gl-border-solid gl-border-gray-400 gl-display-inline-block gl-p-2"
+ ></i>
</div>
</div>
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
index 133513d6c21..33d6eb139f7 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -22,6 +22,7 @@ import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_t
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';
+import { MAX_LABELS } from '../constants';
export default {
name: 'FilterBar',
@@ -70,6 +71,7 @@ export default {
symbol: '~',
operators: OPERATORS_IS,
fetchLabels: this.fetchLabels,
+ maxSuggestions: MAX_LABELS,
},
{
icon: 'pencil',
@@ -146,6 +148,7 @@ export default {
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
:initial-filter-value="initialFilterValue()"
+ terms-as-tokens
@onFilter="handleFilter"
/>
<url-sync :query="query" />
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
index b9d1c4b0fe0..0de62013a63 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
@@ -82,6 +82,7 @@ export default {
<div>
<projects-dropdown-filter
v-if="hasProjectFilter"
+ toggle-classes="gl-max-w-26"
class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
:group-namespace="groupPath"
:query-params="projectsQueryParams"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js
index bea562fb18c..c14f3cfc6c9 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/constants.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js
@@ -43,3 +43,4 @@ export const METRICS_REQUESTS = [
export const MILESTONES_ENDPOINT = '/-/milestones.json';
export const LABELS_ENDPOINT = '/-/labels.json';
+export const MAX_LABELS = 100;
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index 98193de4a12..f881c924ae5 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlIcon,
- GlLoadingIcon,
- GlAvatar,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
- GlTruncate,
-} from '@gitlab/ui';
+import { GlButton, GlIcon, GlAvatar, GlCollapsibleListbox, GlTruncate } from '@gitlab/ui';
import { debounce } from 'lodash';
import { filterBySearchTerm } from '~/analytics/shared/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -18,17 +9,15 @@ import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
+const mapItemToListboxFormat = (item) => ({ ...item, value: item.id, text: item.name });
export default {
name: 'ProjectsDropdownFilter',
components: {
+ GlButton,
GlIcon,
- GlLoadingIcon,
GlAvatar,
- GlDropdown,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
GlTruncate,
},
props: {
@@ -61,6 +50,11 @@ export default {
required: false,
default: false,
},
+ toggleClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -94,6 +88,9 @@ export default {
selectedProjectIds() {
return this.selectedProjects.map((p) => p.id);
},
+ selectedListBoxItems() {
+ return this.multiSelect ? this.selectedProjectIds : this.selectedProjectIds[0];
+ },
hasSelectedProjects() {
return Boolean(this.selectedProjects.length);
},
@@ -110,6 +107,28 @@ export default {
unselectedItems() {
return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
},
+ selectedGroupOptions() {
+ return this.selectedItems.map(mapItemToListboxFormat);
+ },
+ unSelectedGroupOptions() {
+ return this.unselectedItems.map(mapItemToListboxFormat);
+ },
+ listBoxItems() {
+ if (this.selectedGroupOptions.length === 0) {
+ return this.unSelectedGroupOptions;
+ }
+
+ return [
+ {
+ text: __('Selected'),
+ options: this.selectedGroupOptions,
+ },
+ {
+ text: __('Unselected'),
+ options: this.unSelectedGroupOptions,
+ },
+ ];
+ },
},
watch: {
searchTerm() {
@@ -129,32 +148,29 @@ export default {
search: debounce(function debouncedSearch() {
this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getSelectedProjects(selectedProject, isSelected) {
- return isSelected
- ? this.selectedProjects.concat([selectedProject])
- : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
- },
singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : [];
},
- setSelectedProjects(project) {
+ setSelectedProjects(payload) {
this.selectedProjects = this.multiSelect
- ? this.getSelectedProjects(project, !this.isProjectSelected(project))
- : this.singleSelectedProject(project, !this.isProjectSelected(project));
+ ? payload
+ : this.singleSelectedProject(payload, !this.isProjectSelected(payload));
},
- onClick(project) {
+ onClick(projectId) {
+ const project = this.availableProjects.find(({ id }) => id === projectId);
this.setSelectedProjects(project);
this.handleUpdatedSelectedProjects();
},
- onMultiSelectClick(project) {
- this.setSelectedProjects(project);
+ onMultiSelectClick(projectIds) {
+ const projects = this.availableProjects.filter(({ id }) => projectIds.includes(id));
+ this.setSelectedProjects(projects);
this.isDirty = true;
},
- onSelected(project) {
+ onSelected(payload) {
if (this.multiSelect) {
- this.onMultiSelectClick(project);
+ this.onMultiSelectClick(payload);
} else {
- this.onClick(project);
+ this.onClick(payload);
}
},
onHide() {
@@ -201,97 +217,67 @@ export default {
getEntityId(project) {
return getIdFromGraphQLId(project.id);
},
+ setSearchTerm(val) {
+ this.searchTerm = val;
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
- <gl-dropdown
+ <gl-collapsible-listbox
ref="projectsDropdown"
- class="dropdown dropdown-projects"
- toggle-class="gl-shadow-none gl-mb-0"
+ :header-text="__('Projects')"
+ :items="listBoxItems"
+ :reset-button-label="__('Clear All')"
:loading="loadingDefaultProjects"
- :show-clear-all="hasSelectedProjects"
- show-highlighted-items-title
- highlighted-items-title-class="gl-p-3"
- block
- @clear-all.stop="onClearAll"
- @hide="onHide"
+ :multiple="multiSelect"
+ :no-results-text="__('No matching results')"
+ :selected="selectedListBoxItems"
+ :searching="loading"
+ searchable
+ @hidden="onHide"
+ @reset="onClearAll"
+ @search="setSearchTerm"
+ @select="onSelected"
>
- <template #button-content>
- <gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2 gl-flex-shrink-0" />
- <gl-avatar
- v-if="isOnlyOneProjectSelected"
- :src="selectedProjects[0].avatarUrl"
- :entity-id="getEntityId(selectedProjects[0])"
- :entity-name="selectedProjects[0].name"
- :size="16"
- :shape="$options.AVATAR_SHAPE_OPTION_RECT"
- :alt="selectedProjects[0].name"
- class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0"
- />
- <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" />
- <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" />
- </template>
- <template #header>
- <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
- <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search')" />
- </template>
- <template #highlighted-items>
- <gl-dropdown-item
- v-for="project in selectedItems"
- :key="project.id"
- is-check-item
- :is-checked="isProjectSelected(project)"
- @click.native.capture.stop="onSelected(project)"
+ <template #toggle>
+ <gl-button
+ button-text-classes="gl-w-full gl-justify-content-space-between gl-display-flex gl-shadow-none gl-mb-0"
+ :class="['dropdown-projects', toggleClasses]"
>
- <div class="gl-display-flex">
- <gl-avatar
- class="gl-mr-2 gl-vertical-align-middle"
- :alt="project.name"
- :size="16"
- :entity-id="getEntityId(project)"
- :entity-name="project.name"
- :src="project.avatarUrl"
- :shape="$options.AVATAR_SHAPE_OPTION_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-avatar
+ v-if="isOnlyOneProjectSelected"
+ :src="selectedProjects[0].avatarUrl"
+ :entity-id="getEntityId(selectedProjects[0])"
+ :entity-name="selectedProjects[0].name"
+ :size="16"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :alt="selectedProjects[0].name"
+ class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2 gl-flex-shrink-0"
+ />
+ <gl-truncate :text="selectedProjectsLabel" class="gl-min-w-0 gl-flex-grow-1" />
+ <gl-icon class="gl-ml-2 gl-flex-shrink-0" name="chevron-down" />
+ </gl-button>
</template>
- <gl-dropdown-item
- v-for="project in unselectedItems"
- :key="project.id"
- @click.native.capture.stop="onSelected(project)"
- >
+ <template #list-item="{ item }">
<div class="gl-display-flex">
<gl-avatar
- class="gl-mr-2 vertical-align-middle"
- :alt="project.name"
+ class="gl-mr-2 gl-vertical-align-middle"
+ :alt="item.name"
:size="16"
- :entity-id="getEntityId(project)"
- :entity-name="project.name"
- :src="project.avatarUrl"
+ :entity-id="getEntityId(item)"
+ :entity-name="item.name"
+ :src="item.avatarUrl"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
- <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div>
+ <div data-testid="project-name" data-qa-selector="project_name">{{ item.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
- {{ project.fullPath }}
+ {{ item.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>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index c98cf90f406..25699c17b10 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,4 +1,5 @@
-import { masks } from '~/lib/dateformat';
+import dateFormat, { masks } from '~/lib/dateformat';
+import { nDaysBefore, getStartOfDay } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -13,12 +14,19 @@ export const dateFormats = {
month: 'mmmm',
};
+const startOfToday = getStartOfDay(new Date(), { utc: true });
+const last180Days = nDaysBefore(startOfToday, DATE_RANGE_LIMIT, { utc: true });
+const formatDateParam = (d) => dateFormat(d, dateFormats.isoDate, true);
+
export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details');
-export const KEY_METRICS = {
+export const ISSUES_COMPLETED_TYPE = 'issues_completed';
+
+export const FLOW_METRICS = {
LEAD_TIME: 'lead_time',
CYCLE_TIME: 'cycle_time',
ISSUES: 'issues',
+ ISSUES_COMPLETED: ISSUES_COMPLETED_TYPE,
COMMITS: 'commits',
DEPLOYS: 'deploys',
};
@@ -33,7 +41,7 @@ export const DORA_METRICS = {
const VSA_FLOW_METRICS_GROUP = {
key: 'key_metrics',
title: s__('ValueStreamAnalytics|Key metrics'),
- keys: Object.values(KEY_METRICS),
+ keys: Object.values(FLOW_METRICS),
};
export const VSA_METRICS_GROUPS = [VSA_FLOW_METRICS_GROUP];
@@ -46,6 +54,12 @@ export const VULNERABILITY_METRICS = {
HIGH: VULNERABILITY_HIGH_TYPE,
};
+export const MERGE_REQUEST_THROUGHPUT_TYPE = 'merge_request_throughput';
+
+export const MERGE_REQUEST_METRICS = {
+ THROUGHPUT: MERGE_REQUEST_THROUGHPUT_TYPE,
+};
+
export const METRIC_TOOLTIPS = {
[DORA_METRICS.DEPLOYMENT_FREQUENCY]: {
description: s__(
@@ -79,7 +93,7 @@ export const METRIC_TOOLTIPS = {
projectLink: '-/pipelines/charts?chart=change-failure-rate',
docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }),
},
- [KEY_METRICS.LEAD_TIME]: {
+ [FLOW_METRICS.LEAD_TIME]: {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
groupLink: '-/analytics/value_stream_analytics',
projectLink: '-/value_stream_analytics',
@@ -87,7 +101,7 @@ export const METRIC_TOOLTIPS = {
anchor: 'view-the-lead-time-and-cycle-time-for-issues',
}),
},
- [KEY_METRICS.CYCLE_TIME]: {
+ [FLOW_METRICS.CYCLE_TIME]: {
description: s__(
"ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
@@ -97,13 +111,21 @@ export const METRIC_TOOLTIPS = {
anchor: 'view-the-lead-time-and-cycle-time-for-issues',
}),
},
- [KEY_METRICS.ISSUES]: {
+ [FLOW_METRICS.ISSUES]: {
description: s__('ValueStreamAnalytics|Number of new issues created.'),
groupLink: '-/issues_analytics',
projectLink: '-/analytics/issues_analytics',
docsLink: helpPagePath('user/analytics/issue_analytics'),
},
- [KEY_METRICS.DEPLOYS]: {
+ [FLOW_METRICS.ISSUES_COMPLETED]: {
+ description: s__('ValueStreamAnalytics|Number of issues closed by month.'),
+ groupLink: '-/analytics/value_stream_analytics',
+ projectLink: '-/value_stream_analytics',
+ docsLink: helpPagePath('user/analytics/value_streams_dashboard', {
+ anchor: 'dashboard-metrics-and-drill-down-reports',
+ }),
+ },
+ [FLOW_METRICS.DEPLOYS]: {
description: s__('ValueStreamAnalytics|Total number of deploys to production.'),
groupLink: '-/analytics/productivity_analytics',
projectLink: '-/analytics/merge_request_analytics',
@@ -111,15 +133,25 @@ export const METRIC_TOOLTIPS = {
},
[VULNERABILITY_METRICS.CRITICAL]: {
description: s__('ValueStreamAnalytics|Critical vulnerabilities over time.'),
- groupLink: '-/security/vulnerabilities',
- projectLink: '-/security/vulnerability_report',
- docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ groupLink: '-/security/vulnerabilities?severity=CRITICAL',
+ projectLink: '-/security/vulnerability_report?severity=CRITICAL',
+ docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'),
},
[VULNERABILITY_METRICS.HIGH]: {
description: s__('ValueStreamAnalytics|High vulnerabilities over time.'),
- groupLink: '-/security/vulnerabilities',
- projectLink: '-/security/vulnerability_report',
- docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ groupLink: '-/security/vulnerabilities?severity=HIGH',
+ projectLink: '-/security/vulnerability_report?severity=HIGH',
+ docsLink: helpPagePath('user/application_security/vulnerabilities/severities.html'),
+ },
+ [MERGE_REQUEST_METRICS.THROUGHPUT]: {
+ description: s__('ValueStreamAnalytics|The number of merge requests merged by month.'),
+ groupLink: '-/analytics/productivity_analytics',
+ projectLink: `-/analytics/merge_request_analytics?start_date=${formatDateParam(
+ last180Days,
+ )}&end_date=${formatDateParam(startOfToday)}`,
+ docsLink: helpPagePath('user/analytics/merge_request_analytics', {
+ anchor: 'view-the-number-of-merge-requests-in-a-date-range',
+ }),
},
};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 87c74438d00..95da3b3cf49 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -97,6 +97,7 @@ const Api = {
secureFilePath: '/api/:version/projects/:project_id/secure_files/:secure_file_id',
secureFilesPath: '/api/:version/projects/:project_id/secure_files',
dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache',
+ markdownPath: '/api/:version/markdown',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -1017,6 +1018,12 @@ const Api = {
return axios.delete(url, { params: { ...options } });
},
+
+ markdown(data = {}) {
+ const url = Api.buildUrl(this.markdownPath);
+
+ return axios.post(url, data);
+ },
};
export default Api;
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 3ebb07807d2..17ad1a0b31d 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -10,6 +10,7 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects';
const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
+const USER_FOLLOWERS_PATH = '/api/:version/users/:id/followers';
const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count';
export function getUsers(query, options) {
@@ -71,6 +72,16 @@ export function unfollowUser(userId) {
return axios.post(url);
}
+export function getUserFollowers(userId, params) {
+ const url = buildApiUrl(USER_FOLLOWERS_PATH).replace(':id', encodeURIComponent(userId));
+ return axios.get(url, {
+ params: {
+ per_page: DEFAULT_PER_PAGE,
+ ...params,
+ },
+ });
+}
+
export function associationsCount(userId) {
const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId));
return axios.get(url);
diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js
index 531b42bc185..86728f1b586 100644
--- a/app/assets/javascripts/artifacts_settings/index.js
+++ b/app/assets/javascripts/artifacts_settings/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import KeepLatestArtifactCheckbox from '~/artifacts_settings/keep_latest_artifact_checkbox.vue';
+import KeepLatestArtifactToggle from '~/artifacts_settings/keep_latest_artifact_toggle.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
@@ -26,7 +26,7 @@ export default (containerId = 'js-artifacts-settings-app') => {
helpPagePath,
},
render(createElement) {
- return createElement(KeepLatestArtifactCheckbox);
+ return createElement(KeepLatestArtifactToggle);
},
});
};
diff --git a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_toggle.vue
index 8e7ccb80784..db7d1057402 100644
--- a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
+++ b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlFormCheckbox, GlLink } from '@gitlab/ui';
+import { GlAlert, GlToggle, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import UpdateKeepLatestArtifactProjectSetting from './graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql';
import GetKeepLatestArtifactProjectSetting from './graphql/queries/get_keep_latest_artifact_project_setting.query.graphql';
@@ -13,12 +13,12 @@ export default {
enabledHelpText: __(
'The latest artifacts created by jobs in the most recent successful pipeline will be stored.',
),
- helpLinkText: __('More information'),
- checkboxText: __('Keep artifacts from most recent successful jobs'),
+ helpLinkText: __('Learn more.'),
+ labelText: __('Keep artifacts from most recent successful jobs'),
},
components: {
GlAlert,
- GlFormCheckbox,
+ GlToggle,
GlLink,
},
inject: {
@@ -95,10 +95,16 @@ export default {
@dismiss="isAlertDismissed = true"
>{{ errorMessage }}</gl-alert
>
- <gl-form-checkbox v-model="keepLatestArtifact" @change="updateSetting"
- ><strong class="gl-mr-3">{{ $options.i18n.checkboxText }}</strong>
- <gl-link :href="helpPagePath">{{ $options.i18n.helpLinkText }}</gl-link>
- <template v-if="!$apollo.loading" #help>{{ helpText }}</template>
- </gl-form-checkbox>
+ <gl-toggle
+ v-model="keepLatestArtifact"
+ :is-loading="$apollo.loading"
+ :label="$options.i18n.labelText"
+ @change="updateSetting"
+ >
+ <template #help>
+ {{ helpText }}
+ <gl-link :href="helpPagePath">{{ $options.i18n.helpLinkText }}</gl-link>
+ </template>
+ </gl-toggle>
</div>
</template>
diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue
index fa9a7782b74..6e3af96cf33 100644
--- a/app/assets/javascripts/authentication/password/components/password_input.vue
+++ b/app/assets/javascripts/authentication/password/components/password_input.vue
@@ -15,27 +15,27 @@ export default {
title: {
type: String,
required: false,
- default: '',
+ default: null,
},
id: {
type: String,
required: false,
- default: '',
+ default: null,
},
minimumPasswordLength: {
type: String,
required: false,
- default: '',
+ default: null,
},
qaSelector: {
type: String,
required: false,
- default: '',
+ default: null,
},
testid: {
type: String,
required: false,
- default: '',
+ default: null,
},
autocomplete: {
type: String,
diff --git a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
index 2ebde10c229..74917da6426 100644
--- a/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
+++ b/app/assets/javascripts/batch_comments/components/diff_file_drafts.vue
@@ -15,11 +15,23 @@ export default {
type: String,
required: true,
},
+ showPin: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ positionType: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapGetters('batchComments', ['draftsForFile']),
drafts() {
- return this.draftsForFile(this.fileHash);
+ return this.draftsForFile(this.fileHash).filter(
+ (f) => f.position?.position_type === this.positionType,
+ );
},
},
};
@@ -34,6 +46,7 @@ export default {
>
<div class="notes">
<design-note-pin
+ v-if="showPin"
:label="toggleText(draft, index)"
is-draft
class="js-diff-notes-index gl-translate-x-n50"
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 798ab301c90..cc52285dd81 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { SET_REVIEW_BAR_RENDERED } from '~/batch_comments/stores/modules/batch_comments/mutation_types';
import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
import PreviewDropdown from './preview_dropdown.vue';
import SubmitDropdown from './submit_dropdown.vue';
@@ -23,6 +24,7 @@ export default {
},
mounted() {
document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME);
+ this.$store.commit(`batchComments/${SET_REVIEW_BAR_RENDERED}`);
},
beforeDestroy() {
document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME);
@@ -34,7 +36,7 @@ export default {
</script>
<template>
<div>
- <nav class="review-bar-component" data-testid="review_bar_component">
+ <nav class="review-bar-component js-review-bar" 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/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index d2db61e096a..e6c3a0cba58 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -54,7 +54,7 @@ export default {
// whenever a item in the autocomplete dropdown is clicked
const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler;
this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => {
- if (!e.target.closest('.atwho-container')) {
+ if (!e.composedPath().includes(this.$el)) {
originalClickOutHandler(e);
}
};
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 f6eae7c0c83..45e7256a734 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
@@ -2,6 +2,7 @@ import { isEmpty } from 'lodash';
import { createAlert } from '~/alert';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
import service from '../../../services/drafts_service';
import * as types from './mutation_types';
@@ -23,12 +24,17 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
});
});
-export const createNewDraft = ({ commit }, { endpoint, data }) =>
+export const createNewDraft = ({ commit, dispatch }, { endpoint, data }) =>
service
.createNewDraft(endpoint, data)
.then((res) => res.data)
.then((res) => {
commit(types.ADD_NEW_DRAFT, res);
+
+ if (res.position?.position_type === FILE_DIFF_POSITION_TYPE) {
+ dispatch('diffs/addDraftToFile', { filePath: res.file_path, draft: res }, { root: true });
+ }
+
return res;
})
.catch(() => {
@@ -56,7 +62,9 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) =>
.then((data) => commit(types.SET_BATCH_COMMENTS_DRAFTS, data))
.then(() => {
state.drafts.forEach((draft) => {
- if (!draft.line_code) {
+ if (draft.position?.position_type === FILE_DIFF_POSITION_TYPE) {
+ dispatch('diffs/addDraftToFile', { filePath: draft.file_path, draft }, { root: true });
+ } else if (!draft.line_code) {
dispatch('convertToDiscussion', draft.discussion_id, { root: true });
}
});
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
index 75e4ae63c18..28b9100c5f3 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js
@@ -71,7 +71,7 @@ export const draftsForLine = (state, getters) => (diffFileSha, line, side = null
const showDraftsForThisSide = showDraftOnSide(line, side);
if (showDraftsForThisSide && draftsForFile?.[key]) {
- return draftsForFile[key];
+ return draftsForFile[key].filter((d) => d.position.position_type === 'text');
}
return [];
};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
index 67bcc53ac7d..2000ee69bad 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -16,3 +16,5 @@ export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
export const CLEAR_DRAFTS = 'CLEAR_DRAFTS';
+
+export const SET_REVIEW_BAR_RENDERED = 'SET_REVIEW_BAR_RENDERED';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
index 7961cf134be..453dc861702 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -68,4 +68,7 @@ export default {
[types.CLEAR_DRAFTS](state) {
state.drafts = [];
},
+ [types.SET_REVIEW_BAR_RENDERED](state) {
+ state.reviewBarRendered = true;
+ },
};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
index 10033ba17f9..1efc00059d0 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -5,4 +5,5 @@ export default () => ({
isPublishing: false,
currentlyPublishingDrafts: [],
shouldAnimateReviewButton: false,
+ reviewBarRendered: false,
});
diff --git a/app/assets/javascripts/behaviors/markdown/utils.js b/app/assets/javascripts/behaviors/markdown/utils.js
new file mode 100644
index 00000000000..f02d6c0f813
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/utils.js
@@ -0,0 +1,27 @@
+/**
+ * This method parses raw markdown text in GFM input field and toggles checkboxes
+ * based on checkboxChecked property.
+ *
+ * @param {Object} object containing rawMarkdown, sourcepos, checkboxChecked properties
+ * @returns String with toggled checkboxes
+ */
+export const toggleMarkCheckboxes = ({ rawMarkdown, sourcepos, checkboxChecked }) => {
+ // Extract the description text
+ const [startRange] = sourcepos.split('-');
+ let [startRow] = startRange.split(':');
+ startRow = Number(startRow) - 1;
+
+ // Mark/Unmark the checkboxes
+ return rawMarkdown
+ .split('\n')
+ .map((row, index) => {
+ if (startRow === index) {
+ if (checkboxChecked) {
+ return row.replace(/\[ \]/, '[x]');
+ }
+ return row.replace(/\[[x~]\]/i, '[ ]');
+ }
+ return row;
+ })
+ .join('\n');
+};
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index a88cc1834ac..bd13bcb35fc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -321,12 +321,6 @@ export const GO_TO_PROJECT_JOBS = {
defaultKeys: ['g j'], // eslint-disable-line @gitlab/require-i18n-strings
};
-export const GO_TO_PROJECT_METRICS = {
- id: 'project.goToMetrics',
- description: __('Go to metrics'),
- defaultKeys: ['g l'], // eslint-disable-line @gitlab/require-i18n-strings
-};
-
export const GO_TO_PROJECT_ENVIRONMENTS = {
id: 'project.goToEnvironments',
description: __('Go to environments'),
@@ -506,30 +500,6 @@ const WEB_IDE_COMMIT = {
customizable: false,
};
-export const METRICS_EXPAND_PANEL = {
- id: 'metrics.expandPanel',
- description: __('Expand panel'),
- defaultKeys: ['e'],
-};
-
-export const METRICS_DOWNLOAD_CSV = {
- id: 'metrics.downloadCSV',
- description: __('Download CSV'),
- defaultKeys: ['d'],
-};
-
-export const METRICS_COPY_LINK_TO_CHART = {
- id: 'metrics.copyLinkToChart',
- description: __('Copy link to chart'),
- defaultKeys: ['c'],
-};
-
-export const METRICS_SHOW_ALERTS = {
- id: 'metrics.showAlerts',
- description: __('Alerts'),
- defaultKeys: ['a'],
-};
-
// All keybinding groups
const GLOBAL_SHORTCUTS_GROUP = {
id: 'globalShortcuts',
@@ -606,7 +576,6 @@ const PROJECT_SHORTCUTS_GROUP = {
GO_TO_PROJECT_MERGE_REQUESTS,
GO_TO_PROJECT_PIPELINES,
GO_TO_PROJECT_JOBS,
- ...(gon.features?.removeMonitorMetrics ? [] : [GO_TO_PROJECT_METRICS]),
GO_TO_PROJECT_ENVIRONMENTS,
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_SNIPPETS,
@@ -670,17 +639,6 @@ const WEB_IDE_SHORTCUTS_GROUP = {
keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT],
};
-const METRICS_SHORTCUTS_GROUP = {
- id: 'metrics',
- name: __('Metrics'),
- keybindings: [
- METRICS_EXPAND_PANEL,
- METRICS_DOWNLOAD_CSV,
- METRICS_COPY_LINK_TO_CHART,
- METRICS_SHOW_ALERTS,
- ],
-};
-
export const MISC_SHORTCUTS_GROUP = {
id: 'misc',
name: __('Miscellaneous'),
@@ -701,7 +659,6 @@ export const keybindingGroups = [
MR_COMMITS_SHORTCUTS_GROUP,
ISSUES_SHORTCUTS_GROUP,
WEB_IDE_SHORTCUTS_GROUP,
- ...(gon.features?.removeMonitorMetrics ? [] : [METRICS_SHORTCUTS_GROUP]),
MISC_SHORTCUTS_GROUP,
];
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index 9e6c9c2e08e..d9dc3aae808 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -17,7 +17,6 @@ import {
GO_TO_PROJECT_SNIPPETS,
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_ENVIRONMENTS,
- GO_TO_PROJECT_METRICS,
GO_TO_PROJECT_WEBIDE,
NEW_ISSUE,
} from './keybindings';
@@ -44,7 +43,6 @@ export default class ShortcutsNavigation extends Shortcuts {
[GO_TO_PROJECT_SNIPPETS, () => findAndFollowLink('.shortcuts-snippets')],
[GO_TO_PROJECT_KUBERNETES, () => findAndFollowLink('.shortcuts-kubernetes')],
[GO_TO_PROJECT_ENVIRONMENTS, () => findAndFollowLink('.shortcuts-environments')],
- [GO_TO_PROJECT_METRICS, () => findAndFollowLink('.shortcuts-metrics')],
[GO_TO_PROJECT_WEBIDE, ShortcutsNavigation.navigateToWebIDE],
[NEW_ISSUE, () => findAndFollowLink('.shortcuts-new-issue')],
]);
diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js
index 935343cca2e..a88ef1c3e21 100644
--- a/app/assets/javascripts/blame/streaming/index.js
+++ b/app/assets/javascripts/blame/streaming/index.js
@@ -1,5 +1,6 @@
import { renderHtmlStreams } from '~/streaming/render_html_streams';
import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
@@ -11,6 +12,7 @@ export async function renderBlamePageStreams(firstStreamPromise) {
if (!element || !firstStreamPromise) return;
const stopAnchorObserver = handleStreamedAnchorLink(element);
+ const relativeTimestampsHandler = handleStreamedRelativeTimestamps(element);
const { dataset } = document.querySelector('#blob-content-holder');
const totalExtraPages = parseInt(dataset.totalExtraPages, 10);
const { pagesUrl } = dataset;
@@ -50,6 +52,8 @@ export async function renderBlamePageStreams(firstStreamPromise) {
});
throw error;
} finally {
+ const stopTimestampObserver = await relativeTimestampsHandler;
+ stopTimestampObserver();
stopAnchorObserver();
document.querySelector('#blame-stream-loading').remove();
}
diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue
index 28e81b83713..ee8bd23f844 100644
--- a/app/assets/javascripts/blob/components/table_contents.vue
+++ b/app/assets/javascripts/blob/components/table_contents.vue
@@ -42,9 +42,6 @@ export default {
}
},
methods: {
- close() {
- this.$refs.disclosureDropdown?.close();
- },
generateHeaders() {
const BASE_PADDING = 16;
const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')];
@@ -72,10 +69,8 @@ export default {
<template>
<gl-disclosure-dropdown
v-if="!isHidden && items.length"
- ref="disclosureDropdown"
icon="list-bulleted"
class="gl-mr-2"
:items="items"
- @action="close"
/>
</template>
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 7ccb66f18a9..e0ecfca75f5 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -10,7 +10,6 @@ import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
import LicenseSelector from './template_selectors/license_selector';
-import MetricsDashboardSelector from './template_selectors/metrics_dashboard_selector';
export default class FileTemplateMediator {
constructor({ editor, currentAction, projectId }) {
@@ -30,7 +29,6 @@ export default class FileTemplateMediator {
this.templateSelectors = [
GitignoreSelector,
BlobCiYamlSelector,
- MetricsDashboardSelector,
DockerfileSelector,
LicenseSelector,
].map((TemplateSelectorClass) => new TemplateSelectorClass({ mediator: this }));
diff --git a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js
deleted file mode 100644
index 8b10b02ae1d..00000000000
--- a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class MetricsDashboardSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'metrics-dashboard-yaml',
- name: '.metrics-dashboard.yml',
- pattern: /(.metrics-dashboard.yml)/,
- type: 'metrics_dashboard_ymls',
- dropdown: '.js-metrics-dashboard-selector',
- wrapper: '.js-metrics-dashboard-selector-wrap',
- };
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => this.reportSelectionName(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 3a22b06c72e..bf77aa4996c 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,18 +1,18 @@
-import { sortBy, cloneDeep } from 'lodash';
+import { sortBy, cloneDeep, find, inRange } from 'lodash';
import {
TYPENAME_BOARD,
TYPENAME_ITERATION,
TYPENAME_MILESTONE,
TYPENAME_USER,
} from '~/graphql_shared/constants';
-import { isGid, convertToGraphQLId } from '~/graphql_shared/utils';
+import { isGid, convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
ListType,
MilestoneIDs,
AssigneeFilterType,
MilestoneFilterType,
boardQuery,
-} from './constants';
+} from 'ee_else_ce/boards/constants';
export function getMilestone() {
return null;
@@ -30,6 +30,17 @@ export function updateListPosition(listObj) {
return { ...listObj, position };
}
+export function calculateNewPosition(listPosition, initialPosition, targetPosition) {
+ if (
+ listPosition === null ||
+ !(inRange(listPosition, initialPosition, targetPosition) || listPosition === targetPosition)
+ ) {
+ return listPosition;
+ }
+ const offset = initialPosition < targetPosition ? -1 : 1;
+ return listPosition + offset;
+}
+
export function formatBoardLists(lists) {
return lists.nodes.reduce((map, list) => {
return {
@@ -191,6 +202,38 @@ export function moveItemListHelper(item, fromList, toList) {
return updatedItem;
}
+export function moveItemVariables({
+ iid,
+ epicId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ isIssue,
+ boardId,
+ itemToMove,
+}) {
+ if (isIssue) {
+ return {
+ iid,
+ boardId,
+ projectPath: itemToMove.referencePath.split(/[#]/)[0],
+ moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
+ moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
+ fromListId: getIdFromGraphQLId(fromListId),
+ toListId: getIdFromGraphQLId(toListId),
+ };
+ }
+ return {
+ epicId,
+ boardId,
+ moveBeforeId,
+ moveAfterId,
+ fromListId,
+ toListId,
+ };
+}
+
export function isListDraggable(list) {
return list.listType !== ListType.backlog && list.listType !== ListType.closed;
}
@@ -318,6 +361,13 @@ export function getBoardQuery(boardType) {
return boardQuery[boardType].query;
}
+export function getListByTypeId(lists, type, id) {
+ // type can be assignee/label/milestone/iteration
+ if (type && id) return find(lists, (l) => l.listType === ListType[type] && l[type]?.id === id);
+
+ return null;
+}
+
export default {
getMilestone,
formatIssue,
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index 90f7059da86..985b9798b36 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -1,4 +1,6 @@
<script>
+import produce from 'immer';
+import { debounce } from 'lodash';
import {
GlTooltipDirective as GlTooltip,
GlButton,
@@ -6,8 +8,12 @@ import {
GlIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import { __ } from '~/locale';
+import { createListMutations, listsQuery, BoardType, ListType } from 'ee_else_ce/boards/constants';
+import boardLabelsQuery from '../graphql/board_labels.query.graphql';
+import { getListByTypeId } from '../boards_util';
export default {
i18n: {
@@ -23,60 +29,150 @@ export default {
directives: {
GlTooltip,
},
- inject: ['scopedLabelsAvailable'],
+ inject: ['scopedLabelsAvailable', 'issuableType', 'fullPath', 'boardType', 'isApolloBoard'],
+ props: {
+ listQueryVariables: {
+ type: Object,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
+ lists: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
selectedId: null,
selectedLabel: null,
selectedIdValid: true,
+ labelsApollo: [],
+ searchTerm: '',
};
},
+ apollo: {
+ labelsApollo: {
+ query: boardLabelsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ searchTerm: this.searchTerm,
+ isGroup: this.boardType === BoardType.group,
+ isProject: this.boardType === BoardType.project,
+ };
+ },
+ update(data) {
+ return data[this.boardType].labels.nodes;
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
...mapState(['labels', 'labelsLoading']),
...mapGetters(['getListByLabelId']),
+ labelsToUse() {
+ return this.isApolloBoard ? this.labelsApollo : this.labels;
+ },
+ isLabelsLoading() {
+ return this.isApolloBoard ? this.$apollo.queries.labelsApollo.loading : this.labelsLoading;
+ },
columnForSelected() {
+ if (this.isApolloBoard) {
+ return getListByTypeId(this.lists, ListType.label, this.selectedId);
+ }
return this.getListByLabelId(this.selectedId);
},
items() {
- return (
- this.labels.map((i) => ({
- ...i,
- text: i.title,
- value: i.id,
- })) || []
- );
+ return (this.labelsToUse || []).map((i) => ({
+ ...i,
+ text: i.title,
+ value: i.id,
+ }));
},
},
created() {
- this.filterItems();
+ if (!this.isApolloBoard) {
+ this.filterItems();
+ }
},
methods: {
- ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
+ ...mapActions(['createList', 'fetchLabels', 'highlightList']),
+ createListApollo({ labelId }) {
+ return this.$apollo.mutate({
+ mutation: createListMutations[this.issuableType].mutation,
+ variables: {
+ labelId,
+ boardId: this.boardId,
+ },
+ update: (
+ store,
+ {
+ data: {
+ boardListCreate: { list },
+ },
+ },
+ ) => {
+ const sourceData = store.readQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ });
+ const data = produce(sourceData, (draftData) => {
+ draftData[this.boardType].board.lists.nodes.push(list);
+ });
+ store.writeQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ data,
+ });
+ this.$emit('highlight-list', list.id);
+ },
+ });
+ },
addList() {
if (!this.selectedLabel) {
this.selectedIdValid = false;
return;
}
- this.setAddColumnFormVisibility(false);
-
if (this.columnForSelected) {
const listId = this.columnForSelected.id;
- this.highlightList(listId);
+ if (this.isApolloBoard) {
+ this.$emit('highlight-list', listId);
+ } else {
+ this.highlightList(listId);
+ }
return;
}
- this.createList({ labelId: this.selectedId });
+ if (this.isApolloBoard) {
+ this.createListApollo({ labelId: this.selectedId });
+ } else {
+ this.createList({ labelId: this.selectedId });
+ }
+
+ this.$emit('setAddColumnFormVisibility', false);
},
filterItems(searchTerm) {
this.fetchLabels(searchTerm);
},
+ onSearch: debounce(function debouncedSearch(searchTerm) {
+ this.searchTerm = searchTerm;
+ if (!this.isApolloBoard) {
+ this.filterItems(searchTerm);
+ }
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+
setSelectedItem(selectedId) {
this.selectedId = selectedId;
- const label = this.labels.find(({ id }) => id === selectedId);
+ const label = this.labelsToUse.find(({ id }) => id === selectedId);
if (!selectedId || !label) {
this.selectedLabel = null;
} else {
@@ -95,8 +191,8 @@ export default {
<template>
<board-add-new-column-form
:selected-id-valid="selectedIdValid"
- @filter-items="filterItems"
@add-list="addList"
+ @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)"
>
<template #dropdown>
<gl-collapsible-listbox
@@ -104,11 +200,11 @@ export default {
:items="items"
searchable
:search-placeholder="__('Search labels')"
- :searching="labelsLoading"
+ :searching="isLabelsLoading"
:selected="selectedId"
:no-results-text="$options.i18n.noResults"
@select="setSelectedItem"
- @search="filterItems"
+ @search="onSearch"
@hidden="onHide"
>
<template #toggle>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index 259423df07f..419d0b41d69 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlFormGroup } from '@gitlab/ui';
-import { mapActions } from 'vuex';
import { __ } from '~/locale';
export default {
@@ -33,7 +32,6 @@ export default {
};
},
methods: {
- ...mapActions(['setAddColumnFormVisibility']),
onSubmit() {
this.$emit('add-list');
},
@@ -83,9 +81,11 @@ export default {
@click="onSubmit"
>{{ $options.i18n.add }}</gl-button
>
- <gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{
- $options.i18n.cancel
- }}</gl-button>
+ <gl-button
+ data-testid="cancelAddNewColumn"
+ @click="$emit('setAddColumnFormVisibility', false)"
+ >{{ $options.i18n.cancel }}</gl-button
+ >
</div>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 14c84d3c4e5..d91c8ab4727 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import Tracking from '~/tracking';
@@ -12,16 +11,20 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
+ props: {
+ isNewListShowing: {
+ type: Boolean,
+ required: true,
+ },
+ },
computed: {
- ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }),
tooltip() {
return this.isNewListShowing ? __('The list creation wizard is already open') : '';
},
},
methods: {
- ...mapActions(['setAddColumnFormVisibility']),
handleClick() {
- this.setAddColumnFormVisibility(true);
+ this.$emit('setAddColumnFormVisibility', true);
this.track('click_button', { label: 'create_list' });
},
},
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 3a247819850..0b9243c07c5 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -35,6 +35,7 @@ export default {
activeListId: '',
boardId: this.initialBoardId,
filterParams: { ...this.initialFilterParams },
+ addColumnFormVisible: false,
isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
apolloError: null,
};
@@ -79,6 +80,7 @@ export default {
computed: {
...mapGetters(['isSidebarOpen']),
listQueryVariables() {
+ if (this.filterParams.groupBy) delete this.filterParams.groupBy;
return {
...(this.isIssueBoard && {
isGroup: this.isGroupBoard,
@@ -129,19 +131,24 @@ export default {
<div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }">
<board-top-bar
:board-id="boardId"
+ :add-column-form-visible="addColumnFormVisible"
:is-swimlanes-on="isSwimlanesOn"
@switchBoard="switchBoard"
@setFilters="setFilters"
+ @setAddColumnFormVisibility="addColumnFormVisible = $event"
@toggleSwimlanes="isShowingEpicsSwimlanes = $event"
/>
<board-content
v-if="!isApolloBoard || boardListsApollo"
:board-id="boardId"
+ :add-column-form-visible="addColumnFormVisible"
:is-swimlanes-on="isSwimlanesOn"
:filter-params="filterParams"
:board-lists-apollo="boardListsApollo"
:apollo-error="apolloError"
+ :list-query-variables="listQueryVariables"
@setActiveList="setActiveId"
+ @setAddColumnFormVisibility="addColumnFormVisible = $event"
/>
<board-settings-sidebar
v-if="!isApolloBoard || activeList"
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
index f58f7838576..19eddbfdd68 100644
--- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -14,6 +14,7 @@ export default {
GlDisclosureDropdown,
},
mixins: [Tracking.mixin()],
+ inject: ['isApolloBoard'],
props: {
item: {
type: Object,
@@ -83,16 +84,20 @@ export default {
});
},
moveToPosition({ positionInList }) {
- this.moveItem({
- itemId: this.item.id,
- itemIid: this.item.iid,
- itemPath: this.item.referencePath,
- fromListId: this.list.id,
- toListId: this.list.id,
- positionInList,
- atIndex: this.index,
- allItemsLoadedInList: !this.listHasNextPage,
- });
+ if (this.isApolloBoard) {
+ this.$emit('moveToPosition', positionInList);
+ } else {
+ this.moveItem({
+ itemId: this.item.id,
+ itemIid: this.item.iid,
+ itemPath: this.item.referencePath,
+ fromListId: this.list.id,
+ toListId: this.list.id,
+ positionInList,
+ atIndex: this.index,
+ allItemsLoadedInList: !this.listHasNextPage,
+ });
+ }
},
selectMoveAction({ text }) {
if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index b2054d76e95..2ee0b4593d6 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -24,12 +24,20 @@ export default {
type: Object,
required: true,
},
+ highlightedListsApollo: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
...mapGetters(['getBoardItemsByList']),
+ highlightedListsToUse() {
+ return this.isApolloBoard ? this.highlightedListsApollo : this.highlightedLists;
+ },
highlighted() {
- return this.highlightedLists.includes(this.list.id);
+ return this.highlightedListsToUse.includes(this.list.id);
},
listItems() {
return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id);
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 8304dfef527..a51e4ddc8f8 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,12 +1,19 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { sortBy } from 'lodash';
+import produce from 'immer';
import Draggable from 'vuedraggable';
import { mapState, mapActions } from 'vuex';
import eventHub from '~/boards/eventhub';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
-import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
+import {
+ DraggableItemTypes,
+ flashAnimationDuration,
+ listsQuery,
+ updateListQueries,
+} from 'ee_else_ce/boards/constants';
+import { calculateNewPosition } from 'ee_else_ce/boards/boards_util';
import BoardColumn from './board_column.vue';
export default {
@@ -20,7 +27,15 @@ export default {
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
- inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'],
+ inject: [
+ 'boardType',
+ 'canAdminList',
+ 'isIssueBoard',
+ 'isEpicBoard',
+ 'disabled',
+ 'issuableType',
+ 'isApolloBoard',
+ ],
props: {
boardId: {
type: String,
@@ -44,16 +59,25 @@ export default {
required: false,
default: null,
},
+ listQueryVariables: {
+ type: Object,
+ required: true,
+ },
+ addColumnFormVisible: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
boardHeight: null,
+ highlightedLists: [],
};
},
computed: {
- ...mapState(['boardLists', 'error', 'addColumnForm']),
- addColumnFormVisible() {
- return this.addColumnForm?.visible;
+ ...mapState(['boardLists', 'error']),
+ boardListsById() {
+ return this.isApolloBoard ? this.boardListsApollo : this.boardLists;
},
boardListsToUse() {
const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists;
@@ -101,6 +125,90 @@ export default {
refetchLists() {
this.$apollo.queries.boardListsApollo.refetch();
},
+ highlightList(listId) {
+ this.highlightedLists.push(listId);
+
+ setTimeout(() => {
+ this.highlightedLists = this.highlightedLists.filter((id) => id !== listId);
+ }, flashAnimationDuration);
+ },
+ updateListPosition({
+ item: {
+ dataset: { listId: movedListId, draggableItemType },
+ },
+ newIndex,
+ to: { children },
+ }) {
+ if (!this.isApolloBoard) {
+ this.moveList({
+ item: {
+ dataset: { listId: movedListId, draggableItemType },
+ },
+ newIndex,
+ to: { children },
+ });
+ return;
+ }
+
+ if (draggableItemType !== DraggableItemTypes.list) {
+ return;
+ }
+
+ const displacedListId = children[newIndex].dataset.listId;
+
+ if (movedListId === displacedListId) {
+ return;
+ }
+ const initialPosition = this.boardListsById[movedListId].position;
+ const targetPosition = this.boardListsById[displacedListId].position;
+
+ try {
+ this.$apollo.mutate({
+ mutation: updateListQueries[this.issuableType].mutation,
+ variables: {
+ listId: movedListId,
+ position: targetPosition,
+ },
+ update: (store) => {
+ const sourceData = store.readQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ });
+ const data = produce(sourceData, (draftData) => {
+ // for current list, new position is already set by Apollo via automatic update
+ const affectedNodes = draftData[this.boardType].board.lists.nodes.filter(
+ (node) => node.id !== movedListId,
+ );
+ affectedNodes.forEach((node) => {
+ // eslint-disable-next-line no-param-reassign
+ node.position = calculateNewPosition(
+ node.position,
+ initialPosition,
+ targetPosition,
+ );
+ });
+ });
+ store.writeQuery({
+ query: listsQuery[this.issuableType].query,
+ variables: this.listQueryVariables,
+ data,
+ });
+ },
+ optimisticResponse: {
+ updateBoardList: {
+ __typename: 'UpdateBoardListPayload',
+ errors: [],
+ list: {
+ ...this.boardListsApollo[movedListId],
+ position: targetPosition,
+ },
+ },
+ },
+ });
+ } catch {
+ // handle error
+ }
+ },
},
};
</script>
@@ -120,7 +228,7 @@ export default {
ref="list"
v-bind="draggableOptions"
class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto"
- @end="moveList"
+ @end="updateListPosition"
>
<board-column
v-for="(list, index) in boardListsToUse"
@@ -129,13 +237,22 @@ export default {
:board-id="boardId"
:list="list"
:filters="filterParams"
+ :highlighted-lists-apollo="highlightedLists"
:data-draggable-item-type="$options.draggableItemTypes.list"
- :class="{ 'gl-xs-display-none!': addColumnFormVisible }"
+ :class="{ 'gl-display-none! gl-sm-display-inline-block!': addColumnFormVisible }"
@setActiveList="$emit('setActiveList', $event)"
/>
<transition name="slide" @after-enter="afterFormEnters">
- <board-add-new-column v-if="addColumnFormVisible" class="gl-xs-w-full!" />
+ <board-add-new-column
+ v-if="addColumnFormVisible"
+ class="gl-xs-w-full!"
+ :board-id="boardId"
+ :list-query-variables="listQueryVariables"
+ :lists="boardListsById"
+ @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)"
+ @highlight-list="highlightList"
+ />
</transition>
</component>
@@ -146,8 +263,21 @@ export default {
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:filters="filterParams"
+ :highlighted-lists="highlightedLists"
@setActiveList="$emit('setActiveList', $event)"
- />
+ @move-list="updateListPosition"
+ >
+ <board-add-new-column
+ v-if="addColumnFormVisible"
+ class="gl-sticky gl-top-5"
+ :filter-params="filterParams"
+ :list-query-variables="listQueryVariables"
+ :board-id="boardId"
+ :lists="boardListsById"
+ @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)"
+ @highlight-list="highlightList"
+ />
+ </epics-swimlanes>
<board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" />
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 5f082066ad4..af309ba9912 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -9,6 +9,7 @@ import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
DEFAULT_BOARD_LIST_ITEMS_SIZE,
toggleFormEventPrefix,
@@ -16,6 +17,13 @@ import {
listIssuablesQueries,
ListType,
} from 'ee_else_ce/boards/constants';
+import {
+ addItemToList,
+ removeItemFromList,
+ updateEpicsCount,
+ updateIssueCountAndWeight,
+} from '../graphql/cache_updates';
+import { shouldCloneCard, moveItemVariables } from '../boards_util';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
@@ -37,7 +45,7 @@ export default {
GlIntersectionObserver,
BoardCardMoveToPosition,
},
- mixins: [Tracking.mixin()],
+ mixins: [Tracking.mixin(), glFeatureFlagMixin()],
inject: [
'isEpicBoard',
'isGroupBoard',
@@ -73,6 +81,8 @@ export default {
showEpicForm: false,
currentList: null,
isLoadingMore: false,
+ toListId: null,
+ toList: {},
};
},
apollo: {
@@ -111,6 +121,29 @@ export default {
isSingleRequest: true,
},
},
+ toList: {
+ query() {
+ return listIssuablesQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: this.toListId,
+ ...this.listQueryVariables,
+ };
+ },
+ skip() {
+ return !this.toListId;
+ },
+ update(data) {
+ return data[this.boardType].board.lists.nodes[0];
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ error() {
+ // handle error
+ },
+ },
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']),
@@ -205,6 +238,9 @@ export default {
showMoveToPosition() {
return !this.disabled && this.list.listType !== ListType.closed;
},
+ shouldCloneCard() {
+ return shouldCloneCard(this.list.listType, this.toList.listType);
+ },
},
watch: {
boardListItems() {
@@ -337,14 +373,169 @@ export default {
}
}
- this.moveItem({
- itemId,
- itemIid,
- itemPath,
- fromListId: from.dataset.listId,
- toListId: to.dataset.listId,
- moveBeforeId,
- moveAfterId,
+ if (this.isApolloBoard) {
+ this.moveBoardItem(
+ {
+ epicId: itemId,
+ iid: itemIid,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ },
+ newIndex,
+ );
+ } else {
+ this.moveItem({
+ itemId,
+ itemIid,
+ itemPath,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ });
+ }
+ },
+ isItemInTheList(itemIid) {
+ const items = this.toList?.[`${this.issuableType}s`]?.nodes || [];
+ return items.some((item) => item.iid === itemIid);
+ },
+ async moveBoardItem(variables, newIndex) {
+ const { fromListId, toListId, iid } = variables;
+ this.toListId = toListId;
+ await this.$nextTick(); // we need this next tick to retrieve `toList` from Apollo cache
+
+ const itemToMove = this.boardListItems.find((item) => item.iid === iid);
+
+ if (this.shouldCloneCard && this.isItemInTheList(iid)) {
+ return;
+ }
+
+ try {
+ await this.$apollo.mutate({
+ mutation: listIssuablesQueries[this.issuableType].moveMutation,
+ variables: {
+ ...moveItemVariables({
+ ...variables,
+ isIssue: !this.isEpicBoard,
+ boardId: this.boardId,
+ itemToMove,
+ }),
+ withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
+ },
+ update: (cache, { data: { issuableMoveList } }) =>
+ this.updateCacheAfterMovingItem({
+ issuableMoveList,
+ fromListId,
+ toListId,
+ newIndex,
+ cache,
+ }),
+ optimisticResponse: {
+ issuableMoveList: {
+ issuable: itemToMove,
+ errors: [],
+ },
+ },
+ });
+ } catch {
+ // handle error
+ }
+ },
+ updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) {
+ const { issuable } = issuableMoveList;
+ if (!this.shouldCloneCard) {
+ removeItemFromList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: fromListId },
+ boardType: this.boardType,
+ id: issuable.id,
+ issuableType: this.issuableType,
+ cache,
+ });
+ }
+
+ addItemToList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: toListId },
+ issuable,
+ newIndex,
+ boardType: this.boardType,
+ issuableType: this.issuableType,
+ cache,
+ });
+
+ this.updateCountAndWeight({ fromListId, toListId, issuable, cache });
+ },
+ updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) {
+ if (!this.isEpicBoard) {
+ updateIssueCountAndWeight({
+ fromListId,
+ toListId,
+ filterParams: this.filterParams,
+ issuable,
+ shouldClone: isAddingIssue || this.shouldCloneCard,
+ cache,
+ });
+ } else {
+ const { issuableType, filterParams } = this;
+ updateEpicsCount({
+ issuableType,
+ toListId,
+ fromListId,
+ filterParams,
+ issuable,
+ shouldClone: this.shouldCloneCard,
+ cache,
+ });
+ }
+ },
+ moveToPosition(positionInList, oldIndex, item) {
+ this.$apollo.mutate({
+ mutation: listIssuablesQueries[this.issuableType].moveMutation,
+ variables: {
+ ...moveItemVariables({
+ iid: item.iid,
+ epicId: item.id,
+ fromListId: this.currentList.id,
+ toListId: this.currentList.id,
+ isIssue: !this.isEpicBoard,
+ boardId: this.boardId,
+ itemToMove: item,
+ }),
+ positionInList,
+ withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
+ },
+ optimisticResponse: {
+ issuableMoveList: {
+ issuable: item,
+ errors: [],
+ },
+ },
+ update: (cache, { data: { issuableMoveList } }) => {
+ const { issuable } = issuableMoveList;
+ removeItemFromList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: this.currentList.id },
+ boardType: this.boardType,
+ id: issuable.id,
+ issuableType: this.issuableType,
+ cache,
+ });
+ if (positionInList === 0 || this.listItemsCount <= this.boardListItems.length) {
+ const newIndex = positionInList === 0 ? 0 : this.boardListItems.length - 1;
+ addItemToList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: this.currentList.id },
+ issuable,
+ newIndex,
+ boardType: this.boardType,
+ issuableType: this.issuableType,
+ cache,
+ });
+ }
+ },
});
},
},
@@ -401,6 +592,7 @@ export default {
:index="index"
:list="list"
:list-items-length="boardListItems.length"
+ @moveToPosition="moveToPosition($event, index, item)"
/>
<gl-intersection-observer
v-if="isObservableItem(index)"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 1b711feb686..61a9b22bfc5 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -108,6 +108,9 @@ export default {
listType() {
return this.list.listType;
},
+ isLabelList() {
+ return this.listType === ListType.label;
+ },
itemsCount() {
return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
},
@@ -258,9 +261,6 @@ export default {
},
methods: {
...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
- closeListActions() {
- this.$refs.headerListActions?.close();
- },
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -277,8 +277,6 @@ export default {
}
this.track('click_button', { label: 'list_settings' });
-
- this.closeListActions();
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
@@ -292,13 +290,9 @@ export default {
} else {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
}
-
- this.closeListActions();
},
showNewEpicForm() {
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
-
- this.closeListActions();
},
toggleExpanded() {
const collapsed = !this.list.collapsed;
@@ -382,7 +376,8 @@ export default {
<header
:class="{
'gl-h-full': list.collapsed,
- 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader,
+ 'board-inner gl-bg-gray-50': isSwimlanesHeader,
+ 'gl-border-t-solid gl-border-4 gl-rounded-top-left-base gl-rounded-top-right-base': isLabelList,
}"
:style="headerStyle"
class="board-header gl-relative"
@@ -532,7 +527,6 @@ export default {
</div>
<gl-disclosure-dropdown
v-if="showListHeaderActions"
- ref="headerListActions"
v-gl-tooltip.hover.top="{
title: $options.i18n.listActions,
boundary: 'viewport',
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 23e0f2510a7..0f43aae3936 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -114,10 +114,10 @@ export default {
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
- async deleteBoardList() {
+ deleteBoardList() {
this.track('click_button', { label: 'remove_list' });
if (this.isApolloBoard) {
- await this.deleteList(this.activeListId);
+ this.deleteList(this.activeListId);
} else {
this.removeList(this.activeId);
}
@@ -157,7 +157,7 @@ export default {
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append>
<gl-drawer
v-bind="$attrs"
- class="js-board-settings-sidebar gl-absolute"
+ class="js-board-settings-sidebar gl-absolute boards-sidebar"
:open="showSidebar"
variant="sidebar"
@close="unsetActiveListId"
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index c186346b2ac..fd9043a561f 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -35,6 +35,10 @@ export default {
type: String,
required: true,
},
+ addColumnFormVisible: {
+ type: Boolean,
+ required: true,
+ },
isSwimlanesOn: {
type: Boolean,
required: true,
@@ -91,7 +95,7 @@ export default {
class="issues-details-filters filtered-search-block gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row row-content-block second-block"
>
<div
- class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full"
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full gl-min-w-0"
>
<boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" />
<new-board-button />
@@ -117,7 +121,11 @@ export default {
@toggleSwimlanes="$emit('toggleSwimlanes', $event)"
/>
<config-toggle :board-has-scope="hasScope" />
- <board-add-new-column-trigger v-if="canAdminList" />
+ <board-add-new-column-trigger
+ v-if="canAdminList"
+ :is-new-list-showing="addColumnFormVisible"
+ @setAddColumnFormVisibility="$emit('setAddColumnFormVisibility', $event)"
+ />
<toggle-focus />
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 247910301e7..960c8e472b8 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,15 +1,10 @@
<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
- GlIntersectionObserver,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { debounce } from 'lodash';
import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { ListType } from '../constants';
export default {
@@ -27,12 +22,7 @@ export default {
order_by: 'similarity',
},
components: {
- GlIntersectionObserver,
- GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
inject: ['groupId'],
props: {
@@ -44,6 +34,7 @@ export default {
data() {
return {
initialLoading: true,
+ selectedProjectId: '',
selectedProject: {},
searchTerm: '',
};
@@ -51,6 +42,12 @@ export default {
computed: {
...mapState(['groupProjectsFlags']),
...mapGetters(['activeGroupProjects']),
+ projects() {
+ return this.activeGroupProjects.map((project) => ({
+ value: project.id,
+ text: project.nameWithNamespace,
+ }));
+ },
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
@@ -73,26 +70,27 @@ export default {
},
},
watch: {
- searchTerm() {
+ searchTerm: debounce(function debouncedSearch() {
this.fetchGroupProjects({ search: this.searchTerm });
- },
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
},
mounted() {
this.fetchGroupProjects({});
-
this.initialLoading = false;
},
methods: {
...mapActions(['fetchGroupProjects', 'setSelectedProject']),
selectProject(projectId) {
+ this.selectedProjectId = projectId;
this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId);
this.setSelectedProject(this.selectedProject);
},
loadMoreProjects() {
+ if (!this.hasNextPage) return;
this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true });
},
- setFocus() {
- this.$refs.search.focusInput();
+ onSearch(query) {
+ this.searchTerm = query;
},
},
};
@@ -103,45 +101,23 @@ export default {
<label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
$options.i18n.headerTitle
}}</label>
- <gl-dropdown
+ <gl-collapsible-listbox
+ v-model="selectedProjectId"
+ block
+ searchable
+ infinite-scroll
data-testid="project-select-dropdown"
- :text="selectedProjectName"
+ :items="projects"
+ :toggle-text="selectedProjectName"
:header-text="$options.i18n.headerTitle"
- block
- menu-class="gl-w-full!"
:loading="initialLoading"
- @shown="setFocus"
- >
- <gl-search-box-by-type
- ref="search"
- v-model.trim="searchTerm"
- debounce="250"
- :placeholder="$options.i18n.searchPlaceholder"
- />
- <gl-dropdown-item
- v-for="project in activeGroupProjects"
- v-show="!groupProjectsFlags.isLoading"
- :key="project.id"
- :name="project.name"
- @click="selectProject(project.id)"
- >
- {{ project.nameWithNamespace }}
- </gl-dropdown-item>
- <gl-dropdown-text
- v-show="groupProjectsFlags.isLoading"
- data-testid="dropdown-text-loading-icon"
- >
- <gl-loading-icon class="gl-mx-auto" size="sm" />
- </gl-dropdown-text>
- <gl-dropdown-text
- v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading"
- data-testid="empty-result-message"
- >
- <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
- </gl-dropdown-text>
- <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects">
- <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="lg" />
- </gl-intersection-observer>
- </gl-dropdown>
+ :searching="groupProjectsFlags.isLoading"
+ :search-placeholder="$options.i18n.searchPlaceholder"
+ :no-results-text="$options.i18n.emptySearchResult"
+ :infinite-scroll-loading="groupProjectsFlags.isLoadingMore"
+ @select="selectProject"
+ @search="onSearch"
+ @bottom-reached="loadMoreProjects"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 7fe89ffbb52..d4d1bc7804e 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -3,15 +3,18 @@ import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/iss
import { s__, __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
+import createBoardListMutation from './graphql/board_list_create.mutation.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql';
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
+import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql';
import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
+import listDeferredQuery from './graphql/board_lists_deferred.query.graphql';
export const BoardType = {
project: 'project',
@@ -71,6 +74,18 @@ export const listsQuery = {
},
};
+export const listsDeferredQuery = {
+ [TYPE_ISSUE]: {
+ query: listDeferredQuery,
+ },
+};
+
+export const createListMutations = {
+ [TYPE_ISSUE]: {
+ mutation: createBoardListMutation,
+ },
+};
+
export const updateListQueries = {
[TYPE_ISSUE]: {
mutation: updateBoardListMutation,
@@ -110,6 +125,7 @@ export const subscriptionQueries = {
export const listIssuablesQueries = {
[TYPE_ISSUE]: {
query: listIssuesQuery,
+ moveMutation: issueMoveListMutation,
},
};
diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js
new file mode 100644
index 00000000000..084809e4e60
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/cache_updates.js
@@ -0,0 +1,118 @@
+import produce from 'immer';
+import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import { listsDeferredQuery } from 'ee_else_ce/boards/constants';
+
+export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) {
+ cache.updateQuery({ query, variables }, (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ items.splice(
+ items.findIndex((item) => item.id === id),
+ 1,
+ );
+ }),
+ );
+}
+
+export function addItemToList({
+ query,
+ variables,
+ boardType,
+ issuable,
+ newIndex,
+ issuableType,
+ cache,
+}) {
+ cache.updateQuery({ query, variables }, (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ items.splice(newIndex, 0, issuable);
+ }),
+ );
+}
+
+export function updateIssueCountAndWeight({
+ fromListId,
+ toListId,
+ filterParams,
+ issuable: issue,
+ shouldClone,
+ cache,
+}) {
+ if (!shouldClone) {
+ cache.updateQuery(
+ {
+ query: listQuery,
+ variables: { id: fromListId, filters: filterParams },
+ },
+ ({ boardList }) => ({
+ boardList: {
+ ...boardList,
+ issuesCount: boardList.issuesCount - 1,
+ totalWeight: boardList.totalWeight - issue.weight,
+ },
+ }),
+ );
+ }
+
+ cache.updateQuery(
+ {
+ query: listQuery,
+ variables: { id: toListId, filters: filterParams },
+ },
+ ({ boardList }) => ({
+ boardList: {
+ ...boardList,
+ issuesCount: boardList.issuesCount + 1,
+ totalWeight: boardList.totalWeight + issue.weight,
+ },
+ }),
+ );
+}
+
+export function updateEpicsCount({
+ issuableType,
+ filterParams,
+ fromListId,
+ toListId,
+ issuable: epic,
+ shouldClone,
+ cache,
+}) {
+ const epicWeight = epic.descendantWeightSum.openedIssues + epic.descendantWeightSum.closedIssues;
+ if (!shouldClone) {
+ cache.updateQuery(
+ {
+ query: listsDeferredQuery[issuableType].query,
+ variables: { id: fromListId, filters: filterParams },
+ },
+ ({ epicBoardList }) => ({
+ epicBoardList: {
+ ...epicBoardList,
+ metadata: {
+ epicsCount: epicBoardList.metadata.epicsCount - 1,
+ totalWeight: epicBoardList.metadata.totalWeight - epicWeight,
+ ...epicBoardList.metadata,
+ },
+ },
+ }),
+ );
+ }
+
+ cache.updateQuery(
+ {
+ query: listsDeferredQuery[issuableType].query,
+ variables: { id: toListId, filters: filterParams },
+ },
+ ({ epicBoardList }) => ({
+ epicBoardList: {
+ ...epicBoardList,
+ metadata: {
+ epicsCount: epicBoardList.metadata.epicsCount + 1,
+ totalWeight: epicBoardList.metadata.totalWeight + epicWeight,
+ ...epicBoardList.metadata,
+ },
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
index 89670760450..4a46d741a78 100644
--- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
@@ -9,7 +9,7 @@ mutation issueMoveList(
$moveBeforeId: ID
$moveAfterId: ID
) {
- issueMoveList(
+ issuableMoveList: issueMoveList(
input: {
projectPath: $projectPath
iid: $iid
@@ -20,7 +20,7 @@ mutation issueMoveList(
moveAfterId: $moveAfterId
}
) {
- issue {
+ issuable: issue {
...Issue
}
errors
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index a144054d680..d96d92948be 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -602,8 +602,8 @@ export default {
cache,
{
data: {
- issueMoveList: {
- issue: { weight },
+ issuableMoveList: {
+ issuable: { weight },
},
},
},
@@ -661,11 +661,11 @@ export default {
},
});
- if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
+ if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) {
throw new Error('issueMoveList empty');
}
- commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable });
commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
} catch {
commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
diff --git a/app/assets/javascripts/branches/components/branch_more_actions.vue b/app/assets/javascripts/branches/components/branch_more_actions.vue
new file mode 100644
index 00000000000..c646dab2760
--- /dev/null
+++ b/app/assets/javascripts/branches/components/branch_more_actions.vue
@@ -0,0 +1,114 @@
+<script>
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'BranchMoreActions',
+ components: { GlDisclosureDropdown },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ branchName: {
+ type: String,
+ required: true,
+ },
+ defaultBranchName: {
+ type: String,
+ required: true,
+ },
+ canDeleteBranch: {
+ type: Boolean,
+ required: true,
+ },
+ isProtectedBranch: {
+ type: Boolean,
+ required: true,
+ },
+ merged: {
+ type: Boolean,
+ required: true,
+ },
+ comparePath: {
+ type: String,
+ required: true,
+ },
+ deletePath: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ toggleText: __('More actions'),
+ compare: s__('Branches|Compare'),
+ deleteBranch: s__('Branches|Delete branch'),
+ deleteProtectedBranch: s__('Branches|Delete protected branch'),
+ },
+ computed: {
+ deleteBranchText() {
+ return this.isProtectedBranch
+ ? this.$options.i18n.deleteProtectedBranch
+ : this.$options.i18n.deleteBranch;
+ },
+ dropdownItems() {
+ const items = [
+ {
+ text: this.$options.i18n.compare,
+ href: this.comparePath,
+ extraAttrs: {
+ class: 'js-onboarding-compare-branches',
+ 'data-testid': 'compare-branch-button',
+ 'data-method': 'post',
+ },
+ },
+ ];
+
+ if (this.canDeleteBranch) {
+ items.push({
+ text: this.deleteBranchText,
+ action: () => {
+ this.openModal();
+ },
+ extraAttrs: {
+ class: 'js-delete-branch-button gl-text-red-500!',
+ 'aria-label': this.deleteBranchText,
+ 'data-testid': 'delete-branch-button',
+ 'data-qa-selector': 'delete_branch_button',
+ },
+ });
+ }
+
+ return items;
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openModal', {
+ branchName: this.branchName,
+ defaultBranchName: this.defaultBranchName,
+ deletePath: this.deletePath,
+ isProtectedBranch: this.isProtectedBranch,
+ merged: this.merged,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown
+ v-gl-tooltip.hover.top="{
+ title: $options.i18n.toggleText,
+ boundary: 'viewport',
+ }"
+ :items="dropdownItems"
+ :toggle-text="$options.i18n.toggleText"
+ icon="ellipsis_v"
+ category="tertiary"
+ placement="right"
+ data-testid="branch-more-actions"
+ text-sr-only
+ no-caret
+ />
+</template>
diff --git a/app/assets/javascripts/branches/components/delete_branch_button.vue b/app/assets/javascripts/branches/components/delete_branch_button.vue
deleted file mode 100644
index 6a6d4d48c52..00000000000
--- a/app/assets/javascripts/branches/components/delete_branch_button.vue
+++ /dev/null
@@ -1,85 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import eventHub from '../event_hub';
-
-export default {
- name: 'DeleteBranchButton',
- components: { GlButton },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- branchName: {
- type: String,
- required: false,
- default: '',
- },
- defaultBranchName: {
- type: String,
- required: false,
- default: '',
- },
- deletePath: {
- type: String,
- required: false,
- default: '',
- },
- tooltip: {
- type: String,
- required: false,
- default: s__('Branches|Delete branch'),
- },
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- isProtectedBranch: {
- type: Boolean,
- required: false,
- default: false,
- },
- merged: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- title() {
- if (this.isProtectedBranch && this.disabled) {
- return s__('Branches|Only a project maintainer or owner can delete a protected branch');
- } else if (this.isProtectedBranch) {
- return s__('Branches|Delete protected branch');
- }
- return this.tooltip;
- },
- },
- methods: {
- openModal() {
- eventHub.$emit('openModal', {
- branchName: this.branchName,
- defaultBranchName: this.defaultBranchName,
- deletePath: this.deletePath,
- isProtectedBranch: this.isProtectedBranch,
- merged: this.merged,
- });
- },
- },
-};
-</script>
-
-<template>
- <gl-button
- v-gl-tooltip.hover
- icon="remove"
- class="js-delete-branch-button"
- data-qa-selector="delete_branch_button"
- :disabled="disabled"
- variant="default"
- :title="title"
- :aria-label="title"
- @click="openModal"
- />
-</template>
diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
index d9d8f1d742d..117c15be907 100644
--- a/app/assets/javascripts/branches/components/delete_merged_branches.vue
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -103,8 +103,18 @@ export default {
no-caret
placement="right"
data-qa-selector="delete_merged_branches_dropdown_button"
+ class="gl-display-none gl-md-display-block!"
:items="dropdownItems"
/>
+ <gl-button
+ data-qa-selector="delete_merged_branches_button"
+ category="secondary"
+ variant="danger"
+ class="gl-display-block gl-md-display-none!"
+ @click="openModal"
+ >
+ {{ $options.i18n.deleteButtonText }}
+ </gl-button>
<gl-modal
ref="modal"
size="sm"
diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue
index 99c82fc9a5a..4866d506988 100644
--- a/app/assets/javascripts/branches/components/sort_dropdown.vue
+++ b/app/assets/javascripts/branches/components/sort_dropdown.vue
@@ -52,7 +52,7 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-grow-1">
<gl-search-box-by-click
v-model="searchTerm"
:placeholder="$options.i18n.searchPlaceholder"
diff --git a/app/assets/javascripts/branches/init_delete_branch_button.js b/app/assets/javascripts/branches/init_branch_more_actions.js
index 43df5d993a4..62f3c314c43 100644
--- a/app/assets/javascripts/branches/init_delete_branch_button.js
+++ b/app/assets/javascripts/branches/init_branch_more_actions.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
-import DeleteBranchButton from '~/branches/components/delete_branch_button.vue';
+import DeleteBranchButton from '~/branches/components/branch_more_actions.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-export default function initDeleteBranchButton(el) {
+export default function initBranchMoreActions(el) {
if (!el) {
return false;
}
@@ -10,11 +10,11 @@ export default function initDeleteBranchButton(el) {
const {
branchName,
defaultBranchName,
- deletePath,
- tooltip,
- disabled,
+ canDeleteBranch,
isProtectedBranch,
merged,
+ comparePath,
+ deletePath,
} = el.dataset;
return new Vue({
@@ -24,11 +24,11 @@ export default function initDeleteBranchButton(el) {
props: {
branchName,
defaultBranchName,
- deletePath,
- tooltip,
- disabled: parseBoolean(disabled),
+ canDeleteBranch: parseBoolean(canDeleteBranch),
isProtectedBranch: parseBoolean(isProtectedBranch),
merged: parseBoolean(merged),
+ comparePath,
+ deletePath,
},
}),
});
diff --git a/app/assets/javascripts/ci/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
index 5b1c322f07a..d4de42b10a8 100644
--- a/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
@@ -8,12 +8,10 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
I18N_EXPIRED,
I18N_DOWNLOAD,
I18N_DELETE,
- BULK_DELETE_FEATURE_FLAG,
I18N_BULK_DELETE_MAX_SELECTED,
} from '../constants';
@@ -29,7 +27,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: ['canDestroyArtifacts'],
props: {
artifact: {
@@ -66,7 +63,7 @@ export default {
return numberToHumanSize(this.artifact.size);
},
canBulkDestroyArtifacts() {
- return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ return this.canDestroyArtifacts;
},
},
methods: {
diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
index 3f6ea56382f..88334488fdd 100644
--- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
@@ -9,12 +9,12 @@ import {
GlIcon,
GlPagination,
GlFormCheckbox,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
@@ -38,11 +38,11 @@ import {
INITIAL_NEXT_PAGE_CURSOR,
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
- BULK_DELETE_FEATURE_FLAG,
I18N_BULK_DELETE_ERROR,
I18N_BULK_DELETE_PARTIAL_ERROR,
I18N_BULK_DELETE_CONFIRMATION_TOAST,
SELECTED_ARTIFACTS_MAX_COUNT,
+ I18N_BULK_DELETE_MAX_SELECTED,
} from '../constants';
import JobCheckbox from './job_checkbox.vue';
import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
@@ -78,7 +78,9 @@ export default {
ArtifactsTableRowDetails,
FeedbackBanner,
},
- mixins: [glFeatureFlagsMixin()],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
inject: ['projectId', 'projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
@@ -156,7 +158,7 @@ export default {
return this.selectedArtifacts.length >= SELECTED_ARTIFACTS_MAX_COUNT;
},
canBulkDestroyArtifacts() {
- return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ return this.canDestroyArtifacts;
},
isDeletingArtifactsForJob() {
return this.jobArtifactsToDelete.length > 0;
@@ -164,6 +166,25 @@ export default {
artifactsToDelete() {
return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts;
},
+ isAnyVisibleArtifactSelected() {
+ return this.jobArtifacts.some((job) =>
+ job.artifacts.nodes.some((artifactNode) =>
+ this.selectedArtifacts.includes(artifactNode.id),
+ ),
+ );
+ },
+ areAllVisibleArtifactsSelected() {
+ return this.jobArtifacts.every((job) =>
+ job.artifacts.nodes.every((artifactNode) =>
+ this.selectedArtifacts.includes(artifactNode.id),
+ ),
+ );
+ },
+ selectAllTooltipText() {
+ return this.isSelectedArtifactsLimitReached && !this.isAnyVisibleArtifactSelected
+ ? I18N_BULK_DELETE_MAX_SELECTED
+ : '';
+ },
},
methods: {
refetchArtifacts() {
@@ -205,11 +226,11 @@ export default {
}
},
selectArtifact(artifactNode, checked) {
- if (checked) {
- if (!this.isSelectedArtifactsLimitReached) {
- this.selectedArtifacts.push(artifactNode.id);
- }
- } else {
+ const isSelected = this.selectedArtifacts.includes(artifactNode.id);
+
+ if (checked && !isSelected && !this.isSelectedArtifactsLimitReached) {
+ this.selectedArtifacts.push(artifactNode.id);
+ } else if (isSelected) {
this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
}
},
@@ -274,6 +295,11 @@ export default {
this.isBulkDeleteModalVisible = false;
this.jobArtifactsToDelete = [];
},
+ handleSelectAllChecked(checked) {
+ this.jobArtifacts.map((job) =>
+ job.artifacts.nodes.map((artifactNode) => this.selectArtifact(artifactNode, checked)),
+ );
+ },
clearSelectedArtifacts() {
this.selectedArtifacts = [];
},
@@ -284,7 +310,13 @@ export default {
return !job.archive?.downloadPath;
},
browseButtonDisabled(job) {
- return !job.browseArtifactsPath;
+ return !job.browseArtifactsPath || !job.hasMetadata;
+ },
+ browseButtonHref(job) {
+ // make href blank when button is disabled so `cursor: not-allowed` is applied
+ if (this.browseButtonDisabled(job)) return '';
+
+ return job.browseArtifactsPath;
},
deleteButtonDisabled(job) {
return !job.hasArtifacts || !this.canBulkDestroyArtifacts;
@@ -369,10 +401,12 @@ export default {
</template>
<template v-if="canBulkDestroyArtifacts" #head(checkbox)>
<gl-form-checkbox
- :disabled="!anyArtifactsSelected"
- :checked="anyArtifactsSelected"
- :indeterminate="anyArtifactsSelected"
- @change="clearSelectedArtifacts"
+ v-gl-tooltip.right
+ :title="selectAllTooltipText"
+ :checked="isAnyVisibleArtifactSelected"
+ :indeterminate="isAnyVisibleArtifactSelected && !areAllVisibleArtifactsSelected"
+ :disabled="isSelectedArtifactsLimitReached && !isAnyVisibleArtifactSelected"
+ @change="handleSelectAllChecked"
/>
</template>
<template
@@ -469,7 +503,7 @@ export default {
<gl-button
icon="folder-open"
:disabled="browseButtonDisabled(item)"
- :href="item.browseArtifactsPath"
+ :href="browseButtonHref(item)"
:title="$options.i18n.browse"
:aria-label="$options.i18n.browse"
data-testid="job-artifacts-browse-button"
diff --git a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
index 91296bd507e..861278147e9 100644
--- a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
@@ -48,7 +48,7 @@ export default {
},
},
methods: {
- handleInput(checked) {
+ handleChange(checked) {
if (checked) {
this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true));
} else {
@@ -65,6 +65,6 @@ export default {
:disabled="disabled"
:checked="checked"
:indeterminate="indeterminate"
- @input="handleInput"
+ @change="handleChange"
/>
</template>
diff --git a/app/assets/javascripts/ci/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js
index 7ba65e0f98f..2d89b6541f3 100644
--- a/app/assets/javascripts/ci/artifacts/constants.js
+++ b/app/assets/javascripts/ci/artifacts/constants.js
@@ -54,7 +54,6 @@ export const I18N_FEEDBACK_BANNER_BODY = s__(
export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
-export const BULK_DELETE_FEATURE_FLAG = 'ciJobArtifactBulkDestroy';
export const SELECTED_ARTIFACTS_MAX_COUNT = 50;
export const I18N_BULK_DELETE_MAX_SELECTED = s__(
'Artifacts|Maximum selected artifacts limit reached',
@@ -104,6 +103,7 @@ export const JOBS_PER_PAGE = 20;
export const INITIAL_LAST_PAGE_SIZE = null;
export const ARCHIVE_FILE_TYPE = 'ARCHIVE';
+export const METADATA_FILE_TYPE = 'METADATA';
export const ARTIFACT_ROW_HEIGHT = 56;
export const ARTIFACTS_SHOWN_WITHOUT_SCROLLING = 4;
diff --git a/app/assets/javascripts/ci/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js
index ebcf0af8d2a..74ade7d48aa 100644
--- a/app/assets/javascripts/ci/artifacts/utils.js
+++ b/app/assets/javascripts/ci/artifacts/utils.js
@@ -1,10 +1,10 @@
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { ARCHIVE_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants';
+import { ARCHIVE_FILE_TYPE, METADATA_FILE_TYPE, JOB_STATUS_GROUP_SUCCESS } from './constants';
export const totalArtifactsSizeForJob = (job) =>
numberToHumanSize(
job.artifacts.nodes
- .map((artifact) => artifact.size)
+ .map((artifact) => Number(artifact.size))
.reduce((total, artifact) => total + artifact, 0),
);
@@ -21,6 +21,9 @@ export const mapBooleansToJobNodes = (jobNode) => {
return {
succeeded: jobNode.detailedStatus.group === JOB_STATUS_GROUP_SUCCESS,
hasArtifacts: jobNode.artifacts.nodes.length > 0,
+ hasMetadata: jobNode.artifacts.nodes.some(
+ (artifact) => artifact.fileType === METADATA_FILE_TYPE,
+ ),
...jobNode,
};
};
diff --git a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue
index 49a314e067c..39573b2180b 100644
--- a/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue
@@ -108,7 +108,7 @@ export default {
@click="lint"
>{{ __('Validate') }}</gl-button
>
- <gl-form-checkbox v-model="dryRun"
+ <gl-form-checkbox v-model="dryRun" data-testid="ci-lint-dryrun"
>{{ __('Simulate a pipeline created for the default branch') }}
<gl-link :href="pipelineSimulationHelpPagePath" target="_blank"
><gl-icon class="gl-text-blue-600" name="question-o" /></gl-link
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index b3ecaceba69..41514d2d2f1 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -125,7 +125,7 @@ export default {
return regex.test(this.variable.value);
},
canSubmit() {
- return this.variableValidationState && this.variable.key !== '' && this.variable.value !== '';
+ return this.variableValidationState && this.variable.key !== '';
},
containsVariableReference() {
const regex = /\$/;
@@ -154,7 +154,9 @@ export default {
return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
},
maskedFeedback() {
- return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ return this.displayMaskedError
+ ? __('This variable value does not meet the masking requirements.')
+ : '';
},
maskedState() {
if (this.displayMaskedError) {
@@ -190,6 +192,11 @@ export default {
variableValidationState() {
return this.variable.value === '' || (this.tokenValidationState && this.maskedState);
},
+ variableValueHelpText() {
+ return this.variable.masked
+ ? __('Value must meet regular expression requirements to be masked.')
+ : '';
+ },
},
watch: {
variable: {
@@ -324,6 +331,7 @@ export default {
:label="__('Value')"
label-for="ci-variable-value"
:state="variableValidationState"
+ :description="variableValueHelpText"
:invalid-feedback="variableValidationFeedback"
>
<gl-form-textarea
@@ -423,17 +431,19 @@ export default {
>
{{ __('Mask variable') }}
<p class="gl-mt-2 text-secondary">
- {{ __('Variable will be masked in job logs.') }}
- <span
- :class="{
- 'bold text-plain': displayMaskedError,
- }"
- >
- {{ __('Requires values to meet regular expression requirements.') }}</span
+ <gl-sprintf
+ :message="
+ __(
+ 'Mask this variable in job logs if it meets %{linkStart}regular expression requirements%{linkEnd}.',
+ )
+ "
>
- <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
- __('Learn more.')
- }}</gl-link>
+ <template #link="{ content }"
+ ><gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
</p>
</gl-form-checkbox>
<gl-form-checkbox
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index 6f6c55e07c7..ec7a921664f 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
@@ -1,10 +1,12 @@
<script>
import {
GlAlert,
+ GlBadge,
GlButton,
GlLoadingIcon,
GlModalDirective,
GlKeysetPagination,
+ GlLink,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -15,18 +17,13 @@ import {
DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT,
EXCEEDS_VARIABLE_LIMIT_TEXT,
MAXIMUM_VARIABLE_LIMIT_REACHED,
- variableText,
+ variableTypes,
} from '../constants';
import { convertEnvironmentScope } from '../utils';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
- fields: [
- {
- key: 'variableType',
- label: s__('CiVariables|Type'),
- thClass: 'gl-w-10p',
- },
+ defaultFields: [
{
key: 'key',
label: s__('CiVariables|Key'),
@@ -36,12 +33,11 @@ export default {
{
key: 'value',
label: s__('CiVariables|Value'),
- thClass: 'gl-w-15p',
},
{
- key: 'options',
- label: s__('CiVariables|Options'),
- thClass: 'gl-w-10p',
+ key: 'Attributes',
+ label: s__('CiVariables|Attributes'),
+ thClass: 'gl-w-40p',
},
{
key: 'environmentScope',
@@ -54,10 +50,31 @@ export default {
thClass: 'gl-w-5p',
},
],
+ inheritedVarsFields: [
+ {
+ key: 'key',
+ label: s__('CiVariables|Key'),
+ tdClass: 'text-plain',
+ },
+ {
+ key: 'Attributes',
+ label: s__('CiVariables|Attributes'),
+ },
+ {
+ key: 'environmentScope',
+ label: s__('CiVariables|Environments'),
+ },
+ {
+ key: 'group',
+ label: s__('CiVariables|Group'),
+ },
+ ],
components: {
GlAlert,
+ GlBadge,
GlButton,
GlKeysetPagination,
+ GlLink,
GlLoadingIcon,
GlTable,
},
@@ -66,6 +83,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
+ inject: ['isInheritedGroupVars'],
props: {
entity: {
type: String,
@@ -112,6 +130,9 @@ export default {
showAlert() {
return !this.isLoading && this.exceedsVariableLimit;
},
+ showPagination() {
+ return this.glFeatures.ciVariablesPages;
+ },
valuesButtonText() {
return this.areValuesHidden ? __('Reveal values') : __('Hide values');
},
@@ -119,12 +140,17 @@ export default {
return !this.variables || this.variables.length === 0;
},
fields() {
- return this.$options.fields;
+ return this.isInheritedGroupVars
+ ? this.$options.inheritedVarsFields
+ : this.$options.defaultFields;
+ },
+ tableDataTestId() {
+ return this.isInheritedGroupVars ? 'inherited-ci-variable-table' : 'ci-variable-table';
},
- variablesWithOptions() {
+ variablesWithAttributes() {
return this.variables?.map((item, index) => ({
...item,
- options: this.getOptions(item),
+ attributes: this.getAttributes(item),
index,
}));
},
@@ -133,27 +159,27 @@ export default {
convertEnvironmentScopeValue(env) {
return convertEnvironmentScope(env);
},
- generateTypeText(item) {
- return variableText[item.variableType];
- },
toggleHiddenState() {
this.areValuesHidden = !this.areValuesHidden;
},
setSelectedVariable(index = -1) {
this.$emit('set-selected-variable', this.variables[index] ?? null);
},
- getOptions(item) {
- const options = [];
+ getAttributes(item) {
+ const attributes = [];
+ if (item.variableType === variableTypes.fileType) {
+ attributes.push(s__('CiVariables|File'));
+ }
if (item.protected) {
- options.push(s__('CiVariables|Protected'));
+ attributes.push(s__('CiVariables|Protected'));
}
if (item.masked) {
- options.push(s__('CiVariables|Masked'));
+ attributes.push(s__('CiVariables|Masked'));
}
if (!item.raw) {
- options.push(s__('CiVariables|Expanded'));
+ attributes.push(s__('CiVariables|Expanded'));
}
- return options.join(', ');
+ return attributes;
},
},
maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED,
@@ -161,7 +187,7 @@ export default {
</script>
<template>
- <div class="ci-variable-table" data-testid="ci-variable-table">
+ <div class="ci-variable-table" :data-testid="tableDataTestId">
<gl-loading-icon v-if="isLoading" />
<gl-alert
v-if="showAlert"
@@ -172,7 +198,7 @@ export default {
{{ exceedsVariableLimitText }}
</gl-alert>
<div
- v-if="glFeatures.ciVariablesPages"
+ v-if="showPagination && !isInheritedGroupVars"
class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
>
<gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
@@ -191,13 +217,11 @@ export default {
<gl-table
v-if="!isLoading"
:fields="fields"
- :items="variablesWithOptions"
+ :items="variablesWithAttributes"
tbody-tr-class="js-ci-variable-row"
- data-qa-selector="ci_variable_table_content"
sort-by="key"
sort-direction="asc"
stacked="lg"
- table-class="gl-border-t"
fixed
show-empty
sort-icon-left
@@ -208,9 +232,6 @@ export default {
<template #table-colgroup="scope">
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
- <template #cell(variableType)="{ item }">
- {{ generateTypeText(item) }}
- </template>
<template #cell(key)="{ item }">
<div
class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
@@ -231,7 +252,7 @@ export default {
/>
</div>
</template>
- <template #cell(value)="{ item }">
+ <template v-if="!isInheritedGroupVars" #cell(value)="{ item }">
<div
class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
>
@@ -254,8 +275,18 @@ export default {
/>
</div>
</template>
- <template #cell(options)="{ item }">
- <span data-testid="ci-variable-table-row-options">{{ item.options }}</span>
+ <template #cell(attributes)="{ item }">
+ <span data-testid="ci-variable-table-row-attributes">
+ <gl-badge
+ v-for="attribute in item.attributes"
+ :key="`${item.key}-${attribute}`"
+ class="gl-mr-2"
+ variant="info"
+ size="sm"
+ >
+ {{ attribute }}
+ </gl-badge>
+ </span>
</template>
<template #cell(environmentScope)="{ item }">
<div
@@ -277,7 +308,21 @@ export default {
/>
</div>
</template>
- <template #cell(actions)="{ item }">
+ <template v-if="isInheritedGroupVars" #cell(group)="{ item }">
+ <div
+ class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3"
+ >
+ <gl-link
+ :id="`ci-variable-group-${item.id}`"
+ data-testid="ci-variable-table-row-cicd-path"
+ class="gl-display-inline-block gl-max-w-full gl-word-break-word"
+ :href="item.groupCiCdSettingsPath"
+ >
+ {{ item.groupName }}
+ </gl-link>
+ </div>
+ </template>
+ <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }">
<gl-button
v-gl-modal-directive="$options.modalId"
icon="pencil"
@@ -300,28 +345,32 @@ export default {
>
{{ exceedsVariableLimitText }}
</gl-alert>
- <div v-if="!glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-mt-5">
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mr-3"
- data-qa-selector="add_ci_variable_button"
- variant="confirm"
- category="primary"
- :aria-label="__('Add')"
- :disabled="exceedsVariableLimit"
- @click="setSelectedVariable()"
- >{{ __('Add variable') }}</gl-button
- >
- <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
- </div>
- <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6">
- <gl-keyset-pagination
- v-bind="pageInfo"
- :prev-text="__('Previous')"
- :next-text="__('Next')"
- @prev="$emit('handle-prev-page')"
- @next="$emit('handle-next-page')"
- />
+ <div v-if="!isInheritedGroupVars">
+ <div v-if="!showPagination" class="ci-variable-actions gl-display-flex gl-mt-5">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ class="gl-mr-3"
+ data-qa-selector="add_ci_variable_button"
+ variant="confirm"
+ category="primary"
+ :aria-label="__('Add')"
+ :disabled="exceedsVariableLimit"
+ @click="setSelectedVariable()"
+ >{{ __('Add variable') }}</gl-button
+ >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{
+ valuesButtonText
+ }}</gl-button>
+ </div>
+ <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6">
+ <gl-keyset-pagination
+ v-bind="pageInfo"
+ :prev-text="__('Previous')"
+ :next-text="__('Next')"
+ @prev="$emit('handle-prev-page')"
+ @next="$emit('handle-next-page')"
+ />
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index c8f67bd3436..d702dd073ec 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -20,28 +20,14 @@ export const variableTypes = {
fileType: 'FILE',
};
-// Once REST is removed, we won't need `types`
-export const types = {
- variableType: 'env_var',
- fileType: 'file',
-};
-
export const allEnvironments = {
type: '*',
text: __('All (default)'),
};
-// Once REST is removed, we won't need `types` key
-export const variableText = {
- [types.variableType]: __('Variable'),
- [types.fileType]: __('File'),
- [variableTypes.envType]: __('Variable'),
- [variableTypes.fileType]: __('File'),
-};
-
export const variableOptions = [
- { value: variableTypes.envType, text: variableText[variableTypes.envType] },
- { value: variableTypes.fileType, text: variableText[variableTypes.fileType] },
+ { value: variableTypes.envType, text: variableTypes.envType },
+ { value: variableTypes.fileType, text: variableTypes.fileType },
];
export const defaultVariableState = {
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 033cdbe864e..e47b41ceae5 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -67,6 +67,7 @@ const mountCiVariableListApp = (containerEl) => {
groupId,
groupPath,
isGroup: parsedIsGroup,
+ isInheritedGroupVars: false,
isProject: parsedIsProject,
isProtectedByDefault,
maskedEnvironmentVariablesLink,
diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue
new file mode 100644
index 00000000000..27ee1b794f6
--- /dev/null
+++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue
@@ -0,0 +1,110 @@
+<script>
+import { produce } from 'immer';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { reportMessageToSentry } from '~/ci/ci_variable_list/utils';
+import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue';
+import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql';
+
+export const i18n = {
+ fetchError: s__('CiVariables|There was an error fetching the inherited CI variables.'),
+ tooManyCallsError: s__(
+ 'CiVariables|Maximum number of Inherited Group CI variables loaded (2000)',
+ ),
+};
+
+export const VARIABLES_PER_FETCH = 100;
+export const FETCH_LIMIT = 20;
+
+export default {
+ name: 'InheritedCiVariablesApp',
+ components: {
+ CiVariableTable,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectPath'],
+ apollo: {
+ ciVariables: {
+ query: getInheritedCiVariables,
+ variables() {
+ return {
+ first: VARIABLES_PER_FETCH,
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.project.inheritedCiVariables?.nodes || [];
+ },
+ result({ data }) {
+ this.pageInfo = data?.project?.inheritedCiVariables?.pageInfo || this.pageInfo;
+ this.hasNextPage = this.pageInfo?.hasNextPage || false;
+ if (!this.hasNextPage) {
+ return;
+ }
+
+ // The query fetches 100 items at a time.
+ // Variables are batch loaded up to 20 consecutive API calls.
+ if (this.loadingCounter < FETCH_LIMIT) {
+ this.hasNextPage = false;
+ this.fetchMoreVariables();
+ this.loadingCounter += 1;
+ } else {
+ createAlert({ message: this.$options.i18n.tooManyCallsError });
+ reportMessageToSentry(this.$options.name, this.$options.i18n.tooManyCallsError, {});
+ }
+ },
+ error() {
+ this.showFetchError();
+ },
+ },
+ },
+ data() {
+ return {
+ ciVariables: [],
+ hasNextPage: false,
+ loadingCounter: 1,
+ pageInfo: {},
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.ciVariables.loading;
+ },
+ },
+ methods: {
+ fetchMoreVariables() {
+ this.$apollo.queries.ciVariables
+ .fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ const previousVars = previousResult.project.inheritedCiVariables?.nodes;
+ const newVars = fetchMoreResult.project.inheritedCiVariables?.nodes;
+
+ return produce(fetchMoreResult, (draftData) => {
+ draftData.project.inheritedCiVariables.nodes = previousVars.concat(newVars);
+ });
+ },
+ })
+ .catch(this.showFetchError);
+ },
+ showFetchError() {
+ this.hasNextPage = false;
+ createAlert({ message: this.$options.i18n.fetchError });
+ },
+ },
+ i18n,
+};
+</script>
+
+<template>
+ <ci-variable-table
+ entity="project"
+ :is-loading="isLoading"
+ :max-variable-limit="0"
+ :page-info="pageInfo"
+ :variables="ciVariables"
+ />
+</template>
diff --git a/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql
new file mode 100644
index 00000000000..b25768632e1
--- /dev/null
+++ b/app/assets/javascripts/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql
@@ -0,0 +1,24 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query getInheritedCiVariables($after: String, $first: Int, $fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ inheritedCiVariables(after: $after, first: $first) {
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ __typename
+ id
+ key
+ variableType
+ environmentScope
+ groupCiCdSettingsPath
+ groupName
+ masked
+ protected
+ raw
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/inherited_ci_variables/index.js b/app/assets/javascripts/ci/inherited_ci_variables/index.js
new file mode 100644
index 00000000000..324aae2a573
--- /dev/null
+++ b/app/assets/javascripts/ci/inherited_ci_variables/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { generateCacheConfig, resolvers } from '../ci_variable_list/graphql/settings';
+import InheritedCiVariables from './components/inherited_ci_variables_app.vue';
+
+export default (containerId = 'js-inherited-group-ci-variables') => {
+ const el = document.getElementById(containerId);
+
+ if (!el) {
+ return;
+ }
+
+ const { projectPath } = el.dataset;
+
+ Vue.use(VueApollo);
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ resolvers,
+ generateCacheConfig(false), // set to true if we're using key-set pagination
+ ),
+ });
+
+ // eslint-disable-next-line consistent-return
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ isInheritedGroupVars: true,
+ projectPath,
+ },
+ render(createElement) {
+ return createElement(InheritedCiVariables);
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index ea7201efcd9..c2e4c234d2b 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -1,8 +1,9 @@
<script>
import { GlDrawer } from '@gitlab/ui';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { __ } from '~/locale';
-import { DRAWER_CONTAINER_CLASS } from '../job_assistant_drawer/constants';
+import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue';
@@ -31,24 +32,24 @@ export default {
zIndex: {
type: Number,
required: false,
- default: 200,
+ default: DRAWER_Z_INDEX,
},
},
computed: {
- drawerHeightOffset() {
- return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
},
},
methods: {
closeDrawer() {
- this.$emit('close-drawer');
+ this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE);
},
},
};
</script>
<template>
<gl-drawer
- :header-height="drawerHeightOffset"
+ :header-height="getDrawerHeaderHeight"
:open="isVisible"
:z-index="zIndex"
@close="closeDrawer"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
index eabf4749e9c..6ba8884f9a6 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -3,7 +3,14 @@ import { GlButton } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants';
+import {
+ EDITOR_APP_DRAWER_AI_ASSISTANT,
+ EDITOR_APP_DRAWER_HELP,
+ EDITOR_APP_DRAWER_JOB_ASSISTANT,
+ EDITOR_APP_DRAWER_NONE,
+ pipelineEditorTrackingOptions,
+ TEMPLATE_REPOSITORY_URL,
+} from '../../constants';
export default {
i18n: {
@@ -19,7 +26,7 @@ export default {
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
inject: ['aiChatAvailable'],
props: {
- showDrawer: {
+ showHelpDrawer: {
type: Boolean,
required: true,
},
@@ -38,22 +45,24 @@ export default {
},
},
methods: {
- toggleDrawer() {
- if (this.showDrawer) {
- this.$emit('close-drawer');
+ toggleHelpDrawer() {
+ if (this.showHelpDrawer) {
+ this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE);
} else {
- this.$emit('open-drawer');
+ this.$emit('switch-drawer', EDITOR_APP_DRAWER_HELP);
this.trackHelpDrawerClick();
}
},
toggleJobAssistantDrawer() {
this.$emit(
- this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer',
+ 'switch-drawer',
+ this.showJobAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_JOB_ASSISTANT,
);
},
toggleAiAssistantDrawer() {
this.$emit(
- this.showAiAssistantDrawer ? 'close-ai-assistant-drawer' : 'open-ai-assistant-drawer',
+ 'switch-drawer',
+ this.showAiAssistantDrawer ? EDITOR_APP_DRAWER_NONE : EDITOR_APP_DRAWER_AI_ASSISTANT,
);
},
trackHelpDrawerClick() {
@@ -70,7 +79,10 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <div
+ class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1 gl-sm-flex-direction-column"
+ >
+ <slot></slot>
<gl-button
:href="$options.TEMPLATE_REPOSITORY_URL"
size="small"
@@ -87,7 +99,7 @@ export default {
size="small"
data-testid="drawer-toggle"
data-qa-selector="drawer_toggle"
- @click="toggleDrawer"
+ @click="toggleHelpDrawer"
>
{{ $options.i18n.help }}
</gl-button>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue
index ef9acc1f8f1..a410e4c933c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -229,7 +229,6 @@ export default {
<gl-infinite-scroll
:fetched-items="availableBranches.length"
:max-list-height="250"
- data-qa-selector="branch_menu_container"
@bottomReached="fetchNextBranches"
>
<template #items>
@@ -238,7 +237,6 @@ export default {
:key="branch"
:is-checked="currentBranch === branch"
is-check-item
- data-qa-selector="branch_menu_item_button"
@click="selectBranch(branch)"
>
{{ branch }}
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index a4dfb401f4c..656b1a6c347 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -2,7 +2,7 @@
import { __ } from '~/locale';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
-import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '../../constants';
export default {
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
index 372f04075ab..bb79a4d74da 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue
@@ -9,7 +9,9 @@ import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
const POLL_INTERVAL = 10000;
@@ -32,11 +34,13 @@ export default {
GlLink,
GlLoadingIcon,
GlSprintf,
+ GraphqlPipelineMiniGraph,
PipelineEditorMiniGraph,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['projectFullPath'],
props: {
commitSha: {
@@ -106,6 +110,9 @@ export default {
hasPipelineData() {
return Boolean(this.pipeline?.id);
},
+ isUsingPipelineMiniGraphQueries() {
+ return this.glFeatures.ciGraphqlPipelineMiniGraph;
+ },
pipelineId() {
return getIdFromGraphQLId(this.pipeline.id);
},
@@ -171,8 +178,14 @@ export default {
</gl-sprintf>
</span>
</div>
- <div class="gl-display-flex gl-flex-wrap">
- <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" />
+ <div class="gl-display-flex gl-flex-wrap-wrap">
+ <graphql-pipeline-mini-graph
+ v-if="isUsingPipelineMiniGraphQueries"
+ :full-path="projectFullPath"
+ :iid="pipeline.iid"
+ :pipeline-etag="pipelineEtag"
+ />
+ <pipeline-editor-mini-graph v-else :pipeline="pipeline" v-on="$listeners" />
<gl-button
class="gl-ml-3"
category="secondary"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
index 25bbd6b3180..794763e0cd8 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
@@ -1,15 +1,19 @@
<script>
-import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { get, toPath } from 'lodash';
-import { i18n } from '../constants';
+import { i18n, HELP_PATHS } from '../constants';
export default {
i18n,
+ artifactsHelpPath: HELP_PATHS.artifactsHelpPath,
+ cacheHelpPath: HELP_PATHS.cacheHelpPath,
components: {
GlFormGroup,
GlAccordionItem,
GlFormInput,
GlButton,
+ GlLink,
+ GlSprintf,
},
props: {
job: {
@@ -61,6 +65,16 @@ export default {
</script>
<template>
<gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE">
+ <div class="gl-pb-5">
+ <gl-sprintf :message="$options.i18n.ARTIFACTS_AND_CACHE_DESCRIPTION">
+ <template #artifactsLink="{ content }">
+ <gl-link :href="$options.artifactsHelpPath">{{ content }}</gl-link>
+ </template>
+ <template #cacheLink="{ content }">
+ <gl-link :href="$options.cacheHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div v-for="entry in formOptions" :key="entry.key" class="form-group">
<div class="gl-display-flex">
<label class="gl-font-weight-bold gl-mb-3">{{ entry.title }}</label>
@@ -82,6 +96,7 @@ export default {
category="tertiary"
icon="remove"
:data-testid="entry.generateDeleteButtonDataTestId(index)"
+ :aria-label="entry.generateDeleteButtonDataTestId(index)"
@click="deleteStringArrayItem(`${entry.key}[${index}]`)"
/>
</div>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
index b4b468987d8..2c27b66f108 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -1,15 +1,25 @@
<script>
-import { GlFormGroup, GlAccordionItem, GlFormInput, GlFormTextarea } from '@gitlab/ui';
-import { i18n } from '../constants';
+import {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { i18n, HELP_PATHS } from '../constants';
export default {
i18n,
+ helpPath: HELP_PATHS.imageHelpPath,
placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
components: {
GlAccordionItem,
GlFormInput,
GlFormTextarea,
GlFormGroup,
+ GlLink,
+ GlSprintf,
},
props: {
job: {
@@ -26,6 +36,13 @@ export default {
</script>
<template>
<gl-accordion-item :title="$options.i18n.IMAGE">
+ <div class="gl-pb-5">
+ <gl-sprintf :message="$options.i18n.IMAGE_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="$options.helpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<gl-form-group :label="$options.i18n.IMAGE_NAME">
<gl-form-input
:value="job.image.name"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
index d068b370852..d0f206e767f 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
@@ -5,11 +5,14 @@ import {
GlFormInput,
GlFormSelect,
GlFormCheckbox,
+ GlLink,
+ GlSprintf,
} from '@gitlab/ui';
-import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants';
+import { i18n, HELP_PATHS, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants';
export default {
i18n,
+ helpPath: HELP_PATHS.rulesHelpPath,
whenOptions: Object.values(JOB_RULES_WHEN),
unitOptions: Object.values(JOB_RULES_START_IN),
components: {
@@ -18,6 +21,8 @@ export default {
GlFormSelect,
GlFormCheckbox,
GlFormGroup,
+ GlLink,
+ GlSprintf,
},
props: {
job: {
@@ -54,6 +59,13 @@ export default {
</script>
<template>
<gl-accordion-item :title="$options.i18n.RULES">
+ <div class="gl-pb-5">
+ <gl-sprintf :message="$options.i18n.RULES_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="$options.helpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="gl-display-flex">
<gl-form-group class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" :label="$options.i18n.WHEN">
<gl-form-select
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
index 9bada3ef110..0b12d0aedd6 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
@@ -1,9 +1,18 @@
<script>
-import { GlAccordionItem, GlFormInput, GlButton, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
-import { i18n } from '../constants';
+import {
+ GlAccordionItem,
+ GlFormInput,
+ GlButton,
+ GlFormGroup,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { i18n, HELP_PATHS } from '../constants';
export default {
i18n,
+ helpPath: HELP_PATHS.servicesHelpPath,
placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
components: {
GlAccordionItem,
@@ -11,6 +20,8 @@ export default {
GlFormInput,
GlFormTextarea,
GlButton,
+ GlLink,
+ GlSprintf,
},
props: {
job: {
@@ -45,6 +56,13 @@ export default {
</script>
<template>
<gl-accordion-item :title="$options.i18n.SERVICE">
+ <div class="gl-pb-5">
+ <gl-sprintf :message="$options.i18n.SERVICES_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="$options.helpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
<div
v-for="(service, index) in job.services"
:key="index"
@@ -56,6 +74,7 @@ export default {
category="tertiary"
icon="remove"
:data-testid="`delete-job-service-button-${index}`"
+ :aria-label="`delete-job-service-button-${index}`"
@click="deleteService(index)"
/>
<gl-form-group :label="$options.i18n.SERVICE_NAME">
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
index e93a9e84302..087ae992916 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
@@ -1,6 +1,5 @@
import { __, s__ } from '~/locale';
-
-export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
export const JOB_RULES_WHEN = {
onSuccess: {
@@ -115,4 +114,24 @@ export const i18n = {
SERVICE_NAME: s__('JobAssistant|Service name (optional)'),
SERVICE_ENTRYPOINT: s__('JobAssistant|Service entrypoint (optional)'),
ENTRYPOINT_PLACEHOLDER_TEXT: s__('JobAssistant|Please enter the parameters.'),
+ IMAGE_DESCRIPTION: s__(
+ 'JobAssistant|Specify a Docker image that the job runs in. %{linkStart}Learn more%{linkEnd}',
+ ),
+ SERVICES_DESCRIPTION: s__(
+ 'JobAssistant|Specify any additional Docker images that your scripts require to run successfully. %{linkStart}Learn more%{linkEnd}',
+ ),
+ ARTIFACTS_AND_CACHE_DESCRIPTION: s__(
+ 'JobAssistant|Specify the %{artifactsLinkStart}artifacts%{artifactsLinkEnd} and %{cacheLinkStart}cache%{cacheLinkEnd} of the job.',
+ ),
+ RULES_DESCRIPTION: s__(
+ 'JobAssistant|Include or exclude jobs in pipelines. %{linkStart}Learn more%{linkEnd}',
+ ),
+};
+
+export const HELP_PATHS = {
+ artifactsHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#artifacts`,
+ cacheHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#cache`,
+ imageHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#image`,
+ rulesHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#rules`,
+ servicesHelpPath: `${DOCS_URL_IN_EE_DIR}/ci/yaml/#services`,
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
index 30746065732..1a58a112e50 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -2,10 +2,12 @@
import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
import { stringify, parse } from 'yaml';
import { get, omit, toPath } from 'lodash';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants';
import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
-import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants';
+import { JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants';
import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils';
import JobSetupItem from './accordion_items/job_setup_item.vue';
import ImageItem from './accordion_items/image_item.vue';
@@ -34,7 +36,7 @@ export default {
zIndex: {
type: Number,
required: false,
- default: 200,
+ default: DRAWER_Z_INDEX,
},
ciConfigData: {
type: Object,
@@ -78,8 +80,8 @@ export default {
};
});
},
- drawerHeightOffset() {
- return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
},
isJobValid() {
return this.isNameValid && this.isScriptValid && this.isStartValid;
@@ -100,7 +102,7 @@ export default {
methods: {
closeDrawer() {
this.clearJob();
- this.$emit('close-job-assistant-drawer');
+ this.$emit('switch-drawer', EDITOR_APP_DRAWER_NONE);
},
addCiConfig() {
this.validateJob();
@@ -172,7 +174,7 @@ export default {
<template>
<gl-drawer
class="job-assistant-drawer"
- :header-height="drawerHeightOffset"
+ :header-height="getDrawerHeaderHeight"
:open="isVisible"
:z-index="zIndex"
@close="closeDrawer"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
index 403793a255a..a954615ca8a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import CiEditorHeader from 'ee_else_ce/ci/pipeline_editor/components/editor/ci_editor_header.vue';
import { s__, __ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -19,7 +20,6 @@ import {
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
-import CiEditorHeader from './editor/ci_editor_header.vue';
import CiValidate from './validate/ci_validate.vue';
import TextEditor from './editor/text_editor.vue';
import EditorTab from './ui/editor_tab.vue';
@@ -87,19 +87,19 @@ export default {
type: String,
required: true,
},
- isNewCiConfigFile: {
+ showHelpDrawer: {
type: Boolean,
required: true,
},
- showDrawer: {
+ showJobAssistantDrawer: {
type: Boolean,
required: true,
},
- showJobAssistantDrawer: {
+ showAiAssistantDrawer: {
type: Boolean,
required: true,
},
- showAiAssistantDrawer: {
+ isNewCiConfigFile: {
type: Boolean,
required: true,
},
@@ -196,7 +196,7 @@ export default {
>
<walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
<ci-editor-header
- :show-drawer="showDrawer"
+ :show-help-drawer="showHelpDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
:show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
index 912e0fcbff9..e85138e361f 100644
--- a/app/assets/javascripts/ci/pipeline_editor/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -1,5 +1,10 @@
import { s__ } from '~/locale';
+export const EDITOR_APP_DRAWER_HELP = 'HELP';
+export const EDITOR_APP_DRAWER_JOB_ASSISTANT = 'JOB_ASSISTANT';
+export const EDITOR_APP_DRAWER_AI_ASSISTANT = 'AI_ASSISTANT';
+export const EDITOR_APP_DRAWER_NONE = '';
+
// Values for CI_CONFIG_STATUS_* comes from lint graphQL
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
export const CI_CONFIG_STATUS_VALID = 'VALID';
@@ -65,6 +70,7 @@ export const CI_YAML_LINK = 'CI_YAML_LINK';
export const pipelineEditorTrackingOptions = {
label: 'pipeline_editor',
actions: {
+ browseCatalog: 'browse_catalog',
browseTemplates: 'browse_templates',
closeHelpDrawer: 'close_help_drawer',
commitCiConfig: 'commit_ci_config',
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js
index fa1c70c1994..ed5be66d07a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js
@@ -7,30 +7,36 @@ import getPipelineEtag from './queries/client/pipeline_etag.query.graphql';
export const resolvers = {
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
- return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
- valid: data.valid,
- errors: data.errors,
- warnings: data.warnings,
- jobs: data.jobs.map((job) => {
- const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null;
+ return axios.post(endpoint, { content, dry_run }).then(({ data }) => {
+ const { errors, warnings, valid, jobs } = data;
- return {
- name: job.name,
- stage: job.stage,
- beforeScript: job.before_script,
- script: job.script,
- afterScript: job.after_script,
- tags: job.tag_list,
- environment: job.environment,
- when: job.when,
- allowFailure: job.allow_failure,
- only,
- except: job.except,
- __typename: 'CiLintJob',
- };
- }),
- __typename: 'CiLintContent',
- }));
+ return {
+ valid,
+ errors,
+ warnings,
+ jobs: jobs.map((job) => {
+ const only = job.only
+ ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' }
+ : null;
+
+ return {
+ name: job.name,
+ stage: job.stage,
+ beforeScript: job.before_script,
+ script: job.script,
+ afterScript: job.after_script,
+ tags: job.tag_list,
+ environment: job.environment,
+ when: job.when,
+ allowFailure: job.allow_failure,
+ only,
+ except: job.except,
+ __typename: 'CiLintJob',
+ };
+ }),
+ __typename: 'CiLintContent',
+ };
+ });
},
updateAppStatus: (_, { appStatus }, { cache }) => {
cache.writeQuery({
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
index b8d6c27435d..bc20e478876 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -1,17 +1,6 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { EDITOR_APP_STATUS_LOADING } from './constants';
-import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
-import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
-import getAppStatus from './graphql/queries/client/app_status.query.graphql';
-import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql';
-import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql';
-import { resolvers } from './graphql/resolvers';
-import typeDefs from './graphql/typedefs.graphql';
-import PipelineEditorApp from './pipeline_editor_app.vue';
+import { createAppOptions } from 'ee_else_ce/ci/pipeline_editor/options';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
@@ -20,129 +9,9 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
return null;
}
- const {
- // Add to apollo cache as it can be updated by future queries
- initialBranchName,
- pipelineEtag,
- // Add to provide/inject API for static values
- ciConfigPath,
- ciExamplesHelpPagePath,
- ciHelpPagePath,
- ciLintPath,
- ciTroubleshootingPath,
- defaultBranch,
- emptyStateIllustrationPath,
- helpPaths,
- includesHelpPagePath,
- lintHelpPagePath,
- needsHelpPagePath,
- newMergeRequestPath,
- pipelinePagePath,
- projectFullPath,
- projectPath,
- projectNamespace,
- simulatePipelineHelpPagePath,
- totalBranches,
- usesExternalConfig,
- validateTabIllustrationPath,
- ymlHelpPagePath,
- aiChatAvailable,
- } = el.dataset;
+ const options = createAppOptions(el);
- const configurationPaths = Object.fromEntries(
- Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
- source,
- el.dataset[datasetKey],
- ]),
- );
-
- Vue.use(VueApollo);
-
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, {
- typeDefs,
- useGet: true,
- }),
- });
- const { cache } = apolloProvider.clients.defaultClient;
-
- cache.writeQuery({
- query: getAppStatus,
- data: {
- app: {
- __typename: 'PipelineEditorApp',
- status: EDITOR_APP_STATUS_LOADING,
- },
- },
- });
-
- cache.writeQuery({
- query: getCurrentBranch,
- data: {
- workBranches: {
- __typename: 'BranchList',
- current: {
- __typename: 'WorkBranch',
- name: initialBranchName || defaultBranch,
- },
- },
- },
- });
-
- cache.writeQuery({
- query: getLastCommitBranch,
- data: {
- workBranches: {
- __typename: 'BranchList',
- lastCommit: {
- __typename: 'WorkBranch',
- name: '',
- },
- },
- },
- });
-
- cache.writeQuery({
- query: getPipelineEtag,
- data: {
- etags: {
- __typename: 'EtagValues',
- pipeline: pipelineEtag,
- },
- },
- });
-
- return new Vue({
- el,
- apolloProvider,
- provide: {
- aiChatAvailable: parseBoolean(aiChatAvailable),
- ciConfigPath,
- ciExamplesHelpPagePath,
- ciHelpPagePath,
- ciLintPath,
- ciTroubleshootingPath,
- configurationPaths,
- dataMethod: 'graphql',
- defaultBranch,
- emptyStateIllustrationPath,
- helpPaths,
- includesHelpPagePath,
- lintHelpPagePath,
- needsHelpPagePath,
- newMergeRequestPath,
- pipelinePagePath,
- projectFullPath,
- projectPath,
- projectNamespace,
- simulatePipelineHelpPagePath,
- totalBranches: parseInt(totalBranches, 10),
- usesExternalConfig: parseBoolean(usesExternalConfig),
- validateTabIllustrationPath,
- ymlHelpPagePath,
- },
- render(h) {
- return h(PipelineEditorApp);
- },
- });
+ return new Vue(options);
};
+
+initPipelineEditor();
diff --git a/app/assets/javascripts/ci/pipeline_editor/options.js b/app/assets/javascripts/ci/pipeline_editor/options.js
new file mode 100644
index 00000000000..922c8eee8fc
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/options.js
@@ -0,0 +1,142 @@
+import Vue from 'vue';
+
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { EDITOR_APP_STATUS_LOADING } from './constants';
+import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
+import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql';
+import getAppStatus from './graphql/queries/client/app_status.query.graphql';
+import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql';
+import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql';
+import { resolvers } from './graphql/resolvers';
+import typeDefs from './graphql/typedefs.graphql';
+import PipelineEditorApp from './pipeline_editor_app.vue';
+
+export const createAppOptions = (el) => {
+ const {
+ // Add to apollo cache as it can be updated by future queries
+ initialBranchName,
+ pipelineEtag,
+ // Add to provide/inject API for static values
+ ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
+ ciLintPath,
+ ciTroubleshootingPath,
+ defaultBranch,
+ emptyStateIllustrationPath,
+ helpPaths,
+ includesHelpPagePath,
+ lintHelpPagePath,
+ needsHelpPagePath,
+ newMergeRequestPath,
+ pipelinePagePath,
+ projectFullPath,
+ projectPath,
+ projectNamespace,
+ simulatePipelineHelpPagePath,
+ totalBranches,
+ usesExternalConfig,
+ validateTabIllustrationPath,
+ ymlHelpPagePath,
+ aiChatAvailable,
+ } = el.dataset;
+
+ const configurationPaths = Object.fromEntries(
+ Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
+ source,
+ el.dataset[datasetKey],
+ ]),
+ );
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers, {
+ typeDefs,
+ useGet: true,
+ }),
+ });
+ const { cache } = apolloProvider.clients.defaultClient;
+
+ cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: EDITOR_APP_STATUS_LOADING,
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: initialBranchName || defaultBranch,
+ },
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getLastCommitBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: '',
+ },
+ },
+ },
+ });
+
+ cache.writeQuery({
+ query: getPipelineEtag,
+ data: {
+ etags: {
+ __typename: 'EtagValues',
+ pipeline: pipelineEtag,
+ },
+ },
+ });
+
+ return {
+ el,
+ apolloProvider,
+ provide: {
+ aiChatAvailable: parseBoolean(aiChatAvailable),
+ ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
+ ciLintPath,
+ ciTroubleshootingPath,
+ configurationPaths,
+ dataMethod: 'graphql',
+ defaultBranch,
+ emptyStateIllustrationPath,
+ helpPaths,
+ includesHelpPagePath,
+ lintHelpPagePath,
+ needsHelpPagePath,
+ newMergeRequestPath,
+ pipelinePagePath,
+ projectFullPath,
+ projectPath,
+ projectNamespace,
+ simulatePipelineHelpPagePath,
+ totalBranches: parseInt(totalBranches, 10),
+ usesExternalConfig: parseBoolean(usesExternalConfig),
+ validateTabIllustrationPath,
+ ymlHelpPagePath,
+ },
+ render(h) {
+ return h(PipelineEditorApp);
+ },
+ };
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
index 647e33333ce..0495546529a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -1,6 +1,7 @@
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
@@ -9,12 +10,22 @@ import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_na
import PipelineEditorFileTree from './components/file_tree/container.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
-import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants';
+import {
+ CREATE_TAB,
+ FILE_TREE_DISPLAY_KEY,
+ EDITOR_APP_DRAWER_HELP,
+ EDITOR_APP_DRAWER_JOB_ASSISTANT,
+ EDITOR_APP_DRAWER_AI_ASSISTANT,
+ EDITOR_APP_DRAWER_NONE,
+} from './constants';
const AiAssistantDrawer = () =>
import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue');
export default {
+ EDITOR_APP_DRAWER_HELP,
+ EDITOR_APP_DRAWER_JOB_ASSISTANT,
+ EDITOR_APP_DRAWER_AI_ASSISTANT,
commitSectionRef: 'commitSectionRef',
modal: {
switchBranch: {
@@ -67,15 +78,16 @@ export default {
},
data() {
return {
+ currentDrawer: EDITOR_APP_DRAWER_NONE,
currentTab: CREATE_TAB,
scrollToCommitForm: false,
shouldLoadNewBranch: false,
- showDrawer: false,
- showJobAssistantDrawer: false,
- showAiAssistantDrawer: false,
- drawerIndex: 200,
- jobAssistantIndex: 200,
- aiAssistantIndex: 200,
+ currentDrawerIndex: DRAWER_Z_INDEX,
+ drawerIndex: {
+ [EDITOR_APP_DRAWER_HELP]: DRAWER_Z_INDEX,
+ [EDITOR_APP_DRAWER_JOB_ASSISTANT]: DRAWER_Z_INDEX,
+ [EDITOR_APP_DRAWER_AI_ASSISTANT]: DRAWER_Z_INDEX,
+ },
showFileTree: false,
showSwitchBranchModal: false,
};
@@ -87,6 +99,15 @@ export default {
includesFiles() {
return this.ciConfigData?.includes || [];
},
+ showHelpDrawer() {
+ return this.currentDrawer === EDITOR_APP_DRAWER_HELP;
+ },
+ showJobAssistantDrawer() {
+ return this.currentDrawer === EDITOR_APP_DRAWER_JOB_ASSISTANT;
+ },
+ showAiAssistantDrawer() {
+ return this.currentDrawer === EDITOR_APP_DRAWER_AI_ASSISTANT;
+ },
},
mounted() {
this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false;
@@ -95,29 +116,15 @@ export default {
closeBranchModal() {
this.showSwitchBranchModal = false;
},
- closeDrawer() {
- this.showDrawer = false;
- },
- closeJobAssistantDrawer() {
- this.showJobAssistantDrawer = false;
- },
- closeAiAssistantDrawer() {
- this.showAiAssistantDrawer = false;
- },
- openAiAssistantDrawer() {
- this.showAiAssistantDrawer = true;
- this.aiAssistantIndex = this.drawerIndex + 1;
- },
handleConfirmSwitchBranch() {
this.showSwitchBranchModal = true;
},
- openDrawer() {
- this.showDrawer = true;
- this.drawerIndex = this.jobAssistantIndex + 1;
- },
- openJobAssistantDrawer() {
- this.showJobAssistantDrawer = true;
- this.jobAssistantIndex = this.drawerIndex + 1;
+ switchDrawer(drawerName) {
+ this.currentDrawer = drawerName;
+ if (this.drawerIndex[drawerName]) {
+ this.currentDrawerIndex += 1;
+ this.drawerIndex[drawerName] = this.currentDrawerIndex;
+ }
},
toggleFileTree() {
this.showFileTree = !this.showFileTree;
@@ -180,16 +187,11 @@ export default {
:commit-sha="commitSha"
:current-tab="currentTab"
:is-new-ci-config-file="isNewCiConfigFile"
- :show-drawer="showDrawer"
+ :show-help-drawer="showHelpDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
:show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
- @open-drawer="openDrawer"
- @close-drawer="closeDrawer"
- @open-job-assistant-drawer="openJobAssistantDrawer"
- @close-job-assistant-drawer="closeJobAssistantDrawer"
- @open-ai-assistant-drawer="openAiAssistantDrawer"
- @close-ai-assistant-drawer="closeAiAssistantDrawer"
+ @switch-drawer="switchDrawer"
@set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
@@ -207,24 +209,24 @@ export default {
v-on="$listeners"
/>
<pipeline-editor-drawer
- :is-visible="showDrawer"
- :z-index="drawerIndex"
+ :is-visible="showHelpDrawer"
+ :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_HELP]"
v-on="$listeners"
- @close-drawer="closeDrawer"
+ @switch-drawer="switchDrawer"
/>
<job-assistant-drawer
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
:is-visible="showJobAssistantDrawer"
- :z-index="jobAssistantIndex"
+ :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_JOB_ASSISTANT]"
v-on="$listeners"
- @close-job-assistant-drawer="closeJobAssistantDrawer"
+ @switch-drawer="switchDrawer"
/>
<ai-assistant-drawer
v-if="glFeatures.aiCiConfigGenerator"
:is-visible="showAiAssistantDrawer"
- :z-index="aiAssistantIndex"
- @close-ai-assistant-drawer="closeAiAssistantDrawer"
+ :z-index="drawerIndex[$options.EDITOR_APP_DRAWER_AI_ASSISTANT]"
+ @switch-drawer="switchDrawer"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
index 429f8e78dbe..cfcc729b5c9 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
@@ -28,6 +28,13 @@ export default {
required: false,
default: () => ({}),
},
+ queryParams: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sort: 'updated_desc',
+ }),
+ },
},
computed: {
refShortName() {
@@ -51,6 +58,7 @@ export default {
:project-id="projectId"
:translations="$options.i18n"
:use-symbolic-ref-names="true"
+ :query-params="queryParams"
toggle-button-class="gl-w-auto! gl-mb-0!"
@input="setRefSelected"
/>
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue
index f633ba053ee..39ac55bb9c5 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue
@@ -1,5 +1,5 @@
<script>
-import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg';
+import scheduleSvg from '@gitlab/svgs/dist/illustrations/schedule-md.svg?raw';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
index e4d47fba464..f0a41a5949e 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
@@ -1,6 +1,6 @@
<script>
import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
@@ -32,7 +32,7 @@ export default {
message: s__('Runners|Runner created.'),
variant: VARIANT_SUCCESS,
});
- redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ visitUrl(ephemeralRegisterUrl);
},
onError(error) {
createAlert({ message: error.message });
@@ -60,7 +60,7 @@ export default {
<hr aria-hidden="true" />
- <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ <h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Platform') }}
</h2>
<runner-platforms-radio-group v-model="platform" />
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 668a55d2437..d385d32fd9d 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -2,7 +2,7 @@
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -71,7 +71,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
+ visitUrl(this.runnersPath);
},
},
};
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
index 4d04b5d4b14..e287e4e17d1 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
@@ -20,7 +20,13 @@ export default {
},
computed: {
paused() {
- return !this.runner.active;
+ return this.runner.paused;
+ },
+ contactedAt() {
+ return this.runner.contactedAt;
+ },
+ status() {
+ return this.runner.status;
},
},
};
@@ -29,7 +35,8 @@ export default {
<template>
<div>
<runner-status-badge
- :runner="runner"
+ :contacted-at="contactedAt"
+ :status="status"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-paused-badge
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index f24fb5575ae..9f4ce14f704 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -8,6 +8,7 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
import RunnerTags from '../runner_tags.vue';
import RunnerTypeBadge from '../runner_type_badge.vue';
+import RunnerManagersBadge from '../runner_managers_badge.vue';
import { formatJobCount } from '../../utils';
import {
@@ -29,6 +30,7 @@ export default {
RunnerName,
RunnerTags,
RunnerTypeBadge,
+ RunnerManagersBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
UserAvatarLink,
@@ -44,6 +46,9 @@ export default {
},
},
computed: {
+ managersCount() {
+ return this.runner.managers?.count || 0;
+ },
jobCount() {
return formatJobCount(this.runner.jobCount);
},
@@ -75,6 +80,8 @@ export default {
<slot :runner="runner" name="runner-name">
<runner-name :runner="runner" />
</slot>
+
+ <runner-managers-badge :count="managersCount" size="sm" class="gl-vertical-align-middle" />
<gl-icon
v-if="runner.locked"
v-gl-tooltip
@@ -87,7 +94,7 @@ export default {
<div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full">
<template v-if="runner.version">
<div class="gl-flex-shrink-0">
- <runner-upgrade-status-icon :runner="runner" />
+ <runner-upgrade-status-icon :upgrade-status="runner.upgradeStatus" />
<gl-sprintf :message="$options.i18n.I18N_VERSION_LABEL">
<template #version>{{ runner.version }}</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
index ff182c61ccf..9cf2572c924 100644
--- a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
@@ -42,8 +42,8 @@ export default {
};
},
computed: {
- drawerHeightOffset() {
- return getContentWrapperHeight('.content-wrapper');
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
},
architectureOptions() {
return platformArchitectures({ platform: this.selectedPlatform });
@@ -86,7 +86,7 @@ export default {
<template>
<gl-drawer
:open="open"
- :header-height="drawerHeightOffset"
+ :header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
data-testid="runner-platforms-drawer"
@close="onClose"
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
index fe19977f783..6fd4edf5847 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
@@ -1,5 +1,5 @@
<script>
-import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg?url';
+import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/rocket-launch-md.svg?url';
import { GlBanner } from '@gitlab/ui';
import { s__ } from '~/locale';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
index 6107b4dd3ea..1b363174d28 100644
--- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -9,7 +9,7 @@ import {
DEFAULT_ACCESS_LEVEL,
PROJECT_TYPE,
GROUP_TYPE,
- INSTANCE_TYPE,
+ I18N_CREATE_ERROR,
} from '../constants';
export default {
@@ -40,11 +40,13 @@ export default {
return {
saving: false,
runner: {
+ runnerType: this.runnerType,
description: '',
maintenanceNote: '',
paused: false,
accessLevel: DEFAULT_ACCESS_LEVEL,
runUntagged: false,
+ locked: false,
tagList: '',
maximumTimeout: '',
},
@@ -57,26 +59,22 @@ export default {
if (this.runnerType === GROUP_TYPE) {
return {
...input,
- runnerType: GROUP_TYPE,
groupId: this.groupId,
};
}
if (this.runnerType === PROJECT_TYPE) {
return {
...input,
- runnerType: PROJECT_TYPE,
projectId: this.projectId,
};
}
- return {
- ...input,
- runnerType: INSTANCE_TYPE,
- };
+ return input;
},
},
methods: {
async onSubmit() {
this.saving = true;
+
try {
const {
data: {
@@ -90,16 +88,29 @@ export default {
});
if (errors?.length) {
- this.$emit('error', new Error(errors.join(' ')));
- } else {
- this.onSuccess(runner);
+ this.onError(new Error(errors.join(' ')), true);
+ return;
+ }
+
+ if (!runner?.ephemeralRegisterUrl) {
+ // runner is missing information, report issue and
+ // fail naviation to register page.
+ this.onError(new Error(I18N_CREATE_ERROR));
+ return;
}
+
+ this.onSuccess(runner);
} catch (error) {
+ this.onError(error);
+ }
+ },
+ onError(error, isValidationError = false) {
+ if (!isValidationError) {
captureException({ error, component: this.$options.name });
- this.$emit('error', error);
- } finally {
- this.saving = false;
}
+
+ this.$emit('error', error);
+ this.saving = false;
},
onSuccess(runner) {
this.$emit('saved', runner);
@@ -111,9 +122,9 @@ export default {
<gl-form @submit.prevent="onSubmit">
<runner-form-fields v-model="runner" />
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-mt-6">
<gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving">
- {{ __('Submit') }}
+ {{ s__('Runners|Create runner') }}
</gl-button>
</div>
</gl-form>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index 020487fc727..3560521e8d7 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -45,6 +45,9 @@ export default {
runnerName() {
return `#${this.runnerId} (${this.runner.shortSha})`;
},
+ runnerManagersCount() {
+ return this.runner.managers?.count || 0;
+ },
runnerDeleteModalId() {
return `delete-runner-modal-${this.runnerId}`;
},
@@ -150,6 +153,7 @@ export default {
<runner-delete-modal
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
+ :managers-count="runnerManagersCount"
@primary="onDelete"
/>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
index 8be216a7eb5..93f79fd67ea 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_modal.vue
@@ -1,12 +1,9 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__, n__, sprintf } from '~/locale';
const I18N_TITLE = s__('Runners|Delete runner %{name}?');
-const I18N_BODY = s__(
- 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
-);
-const I18N_PRIMARY = s__('Runners|Delete runner');
+const I18N_TITLE_PLURAL = s__('Runners|Delete %{count} runners?');
const I18N_CANCEL = __('Cancel');
export default {
@@ -18,10 +15,40 @@ export default {
type: String,
required: true,
},
+ managersCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
computed: {
+ count() {
+ // Only show count if MORE than 1 manager, for 0 we still
+ // assume 1 runner that happens to be disconnected.
+ return this.managersCount > 1 ? this.managersCount : 1;
+ },
title() {
- return sprintf(I18N_TITLE, { name: this.runnerName });
+ if (this.count === 1) {
+ return sprintf(I18N_TITLE, { name: this.runnerName });
+ }
+ return sprintf(I18N_TITLE_PLURAL, { count: this.count });
+ },
+ body() {
+ return n__(
+ 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ 'Runners|%d runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ this.count,
+ );
+ },
+ actionPrimary() {
+ return {
+ text: n__(
+ 'Runners|Permanently delete runner',
+ 'Runners|Permanently delete %d runners',
+ this.count,
+ ),
+ attributes: { variant: 'danger' },
+ };
},
},
methods: {
@@ -29,9 +56,7 @@ export default {
this.$refs.modal.hide();
},
},
- actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } },
- actionCancel: { text: I18N_CANCEL },
- I18N_BODY,
+ ACTION_CANCEL: { text: I18N_CANCEL },
};
</script>
@@ -40,12 +65,12 @@ export default {
ref="modal"
size="sm"
:title="title"
- :action-primary="$options.actionPrimary"
- :action-cancel="$options.actionCancel"
+ :action-primary="actionPrimary"
+ :action-cancel="$options.ACTION_CANCEL"
v-bind="$attrs"
v-on="$listeners"
@primary="onPrimary"
>
- {{ $options.I18N_BODY }}
+ {{ body }}
</gl-modal>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue
index 6eba8f2e49f..8c1280cffb9 100644
--- a/app/assets/javascripts/ci/runner/components/runner_details.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_details.vue
@@ -1,20 +1,28 @@
<script>
-import { GlIntersperse, GlLink } from '@gitlab/ui';
+import { GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
-import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import {
+ ACCESS_LEVEL_REF_PROTECTED,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ RUNNER_MANAGERS_HELP_URL,
+ I18N_STATUS_NEVER_CONTACTED,
+} from '../constants';
import RunnerDetail from './runner_detail.vue';
import RunnerGroups from './runner_groups.vue';
import RunnerProjects from './runner_projects.vue';
import RunnerTags from './runner_tags.vue';
+import RunnerManagersDetail from './runner_managers_detail.vue';
export default {
components: {
GlIntersperse,
GlLink,
+ GlSprintf,
HelpPopover,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
@@ -26,6 +34,7 @@ export default {
RunnerUpgradeStatusAlert: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_alert.vue'),
RunnerTags,
+ RunnerManagersDetail,
TimeAgo,
},
props: {
@@ -76,6 +85,8 @@ export default {
},
},
ACCESS_LEVEL_REF_PROTECTED,
+ RUNNER_MANAGERS_HELP_URL,
+ I18N_STATUS_NEVER_CONTACTED,
};
</script>
@@ -90,7 +101,7 @@ export default {
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
- :empty-value="s__('Runners|Never contacted')"
+ :empty-value="$options.I18N_STATUS_NEVER_CONTACTED"
>
<template v-if="runner.contactedAt" #value>
<time-ago :time="runner.contactedAt" />
@@ -150,6 +161,33 @@ export default {
class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
:value="runner.maintenanceNoteHtml"
/>
+
+ <runner-detail>
+ <template #label>
+ {{ s__('Runners|Runners') }}
+ <help-popover>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Runners are grouped when they have the same authentication token. This happens when you re-use a runner configuration in more than one runner manager. %{linkStart}How does this work?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }"
+ ><gl-link
+ :href="$options.RUNNER_MANAGERS_HELP_URL"
+ target="_blank"
+ class="gl-reset-font-size"
+ >{{ content }}</gl-link
+ ></template
+ >
+ </gl-sprintf>
+ </help-popover>
+ </template>
+ <template #value>
+ <runner-managers-detail :runner="runner" />
+ </template>
+ </runner-detail>
</dl>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue
index e37ac5e6e26..d090a562ff7 100644
--- a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue
@@ -1,7 +1,16 @@
<script>
-import { GlFormGroup, GlFormCheckbox, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+import {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants';
+import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
export default {
name: 'RunnerFormFields',
@@ -9,8 +18,10 @@ export default {
GlFormGroup,
GlFormCheckbox,
GlFormInput,
+ GlIcon,
GlLink,
GlSprintf,
+ GlSkeletonLoader,
RunnerMaintenanceNoteField: () =>
import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'),
},
@@ -20,15 +31,32 @@ export default {
default: null,
required: false,
},
+ loading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
data() {
return {
- model: {
- ...this.value,
- },
+ model: null,
};
},
+ computed: {
+ canBeLockedToProject() {
+ return this.value?.runnerType === PROJECT_TYPE;
+ },
+ },
watch: {
+ value: {
+ handler(newVal, oldVal) {
+ // update only when values change, avoids infinite loop
+ if (!isEqual(newVal, oldVal)) {
+ this.model = { ...newVal };
+ }
+ },
+ immediate: true,
+ },
model: {
handler() {
this.$emit('input', this.model);
@@ -45,96 +73,122 @@ export default {
</script>
<template>
<div>
- <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ <h2 class="gl-font-size-h2 gl-my-5">
+ {{ s__('Runners|Tags') }}
+ </h2>
+ <gl-skeleton-loader v-if="loading" :lines="12" />
+ <template v-else-if="model">
+ <gl-form-group :label="__('Tags')" label-for="runner-tags">
+ <template #description>
+ <gl-sprintf
+ :message="
+ s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.')
+ "
+ >
+ <template #example>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>macos, shared</code>
+ </template>
+ </gl-sprintf>
+ </template>
+ <template #label-description>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}',
+ )
+ "
+ >
+ <template #helpLink="{ content }">
+ <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" />
+ </gl-form-group>
+ <gl-form-checkbox v-model="model.runUntagged" name="run-untagged">
+ {{ __('Run untagged jobs') }}
+ <template #help>
+ {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }}
+ </template>
+ </gl-form-checkbox>
+ </template>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Details') }}
{{ __('(optional)') }}
</h2>
- <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description">
- <gl-form-input id="runner-description" v-model="model.description" name="description" />
- </gl-form-group>
- <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" />
+ <gl-skeleton-loader v-if="loading" :lines="15" />
+ <template v-else-if="model">
+ <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description">
+ <gl-form-input id="runner-description" v-model="model.description" name="description" />
+ </gl-form-group>
+ <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" />
+ </template>
<hr aria-hidden="true" />
- <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ <h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Configuration') }}
{{ __('(optional)') }}
</h2>
- <div class="gl-mb-5">
- <gl-form-checkbox v-model="model.paused" name="paused">
- {{ __('Paused') }}
- <template #help>
- {{ s__('Runners|Stop the runner from accepting new jobs.') }}
- </template>
- </gl-form-checkbox>
-
- <gl-form-checkbox
- v-model="model.accessLevel"
- name="protected"
- :value="$options.ACCESS_LEVEL_REF_PROTECTED"
- :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
- >
- {{ __('Protected') }}
- <template #help>
- {{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
- </template>
- </gl-form-checkbox>
-
- <gl-form-checkbox v-model="model.runUntagged" name="run-untagged">
- {{ __('Run untagged jobs') }}
- <template #help>
- {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }}
- </template>
- </gl-form-checkbox>
- </div>
+ <gl-skeleton-loader v-if="loading" :lines="15" />
+ <template v-else-if="model">
+ <div class="gl-mb-5">
+ <gl-form-checkbox v-model="model.paused" name="paused">
+ {{ __('Paused') }}
+ <template #help>
+ {{ s__('Runners|Stop the runner from accepting new jobs.') }}
+ </template>
+ </gl-form-checkbox>
- <gl-form-group :label="__('Tags')" label-for="runner-tags">
- <template #description>
- <gl-sprintf
- :message="
- s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.')
- "
+ <gl-form-checkbox
+ v-model="model.accessLevel"
+ name="protected"
+ :value="$options.ACCESS_LEVEL_REF_PROTECTED"
+ :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
>
- <template #example>
- <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
- <code>macos, shared</code>
+ {{ __('Protected') }}
+ <template #help>
+ {{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
</template>
- </gl-sprintf>
- </template>
- <template #label-description>
- <gl-sprintf
- :message="
- s__(
- 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}',
- )
- "
- >
- <template #helpLink="{ content }">
- <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ content }}</gl-link>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox v-if="canBeLockedToProject" v-model="model.locked" name="locked">
+ {{ __('Lock to current projects') }} <gl-icon name="lock" />
+ <template #help>
+ {{
+ s__(
+ 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.',
+ )
+ }}
</template>
- </gl-sprintf>
- </template>
- <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" />
- </gl-form-group>
+ </gl-form-checkbox>
+ </div>
- <gl-form-group
- :label="__('Maximum job timeout')"
- :label-description="
- s__(
- 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.',
- )
- "
- label-for="runner-max-timeout"
- :description="s__('Runners|Enter the number of seconds.')"
- >
- <gl-form-input
- id="runner-max-timeout"
- v-model.number="model.maximumTimeout"
- name="max-timeout"
- type="number"
- />
- </gl-form-group>
+ <gl-form-group
+ :label="__('Maximum job timeout')"
+ :label-description="
+ s__(
+ 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.',
+ )
+ "
+ label-for="runner-max-timeout"
+ :description="s__('Runners|Enter the number of seconds.')"
+ >
+ <gl-form-input
+ id="runner-max-timeout"
+ v-model.number="model.maximumTimeout"
+ name="max-timeout"
+ type="number"
+ />
+ </gl-form-group>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue
index 874c234ca4c..f46e894bf2e 100644
--- a/app/assets/javascripts/ci/runner/components/runner_header.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_header.vue
@@ -1,9 +1,8 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { sprintf } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
+import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
+import { formatRunnerName } from '../utils';
import RunnerTypeBadge from './runner_type_badge.vue';
import RunnerStatusBadge from './runner_status_badge.vue';
@@ -25,12 +24,8 @@ export default {
},
},
computed: {
- paused() {
- return !this.runner.active;
- },
- heading() {
- const id = getIdFromGraphQLId(this.runner.id);
- return sprintf(I18N_DETAILS_TITLE, { runner_id: id });
+ name() {
+ return formatRunnerName(this.runner);
},
},
I18N_LOCKED_RUNNER_DESCRIPTION,
@@ -38,16 +33,16 @@ export default {
</script>
<template>
<div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-py-5"
>
- <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap">
- <runner-status-badge :runner="runner" />
- <runner-type-badge v-if="runner" :type="runner.runnerType" />
- <span>
- <template v-if="runner.createdAt">
- <gl-sprintf :message="__('%{runner} created %{timeago}')">
- <template #runner>
- <strong>{{ heading }}</strong>
+ <div>
+ <h1 class="gl-font-size-h-display gl-my-0">{{ name }}</h1>
+ <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap gl-mt-3">
+ <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" />
+ <runner-type-badge :type="runner.runnerType" />
+ <span v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{locked} created %{timeago}')">
+ <template #locked>
<gl-icon
v-if="runner.locked"
v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
@@ -59,11 +54,8 @@ export default {
<time-ago :time="runner.createdAt" />
</template>
</gl-sprintf>
- </template>
- <template v-else>
- <strong>{{ heading }}</strong>
- </template>
- </span>
+ </span>
+ </div>
</div>
<div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
index c30a824120d..4e68c2ea71a 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
@@ -1,5 +1,5 @@
<script>
-import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url';
import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
@@ -19,7 +19,11 @@ export default {
</script>
<template>
- <gl-empty-state :svg-path="$options.EMPTY_STATE_SVG_URL" :title="$options.i18n.title">
+ <gl-empty-state
+ :svg-path="$options.EMPTY_STATE_SVG_URL"
+ :svg-height="150"
+ :title="$options.i18n.title"
+ >
<template #description>
<p>{{ $options.i18n.description }}</p>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index 8606c22db34..d2836962a97 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
@@ -1,10 +1,20 @@
<script>
-import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
-import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url';
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url';
+import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url';
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import {
+ I18N_GET_STARTED,
+ I18N_RUNNERS_ARE_AGENTS,
+ I18N_CREATE_RUNNER_LINK,
+ I18N_STILL_USING_REGISTRATION_TOKENS,
+ I18N_CONTACT_ADMIN_TO_REGISTER,
+ I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
+ I18N_NO_RESULTS,
+ I18N_EDIT_YOUR_SEARCH,
+} from '~/ci/runner/constants';
export default {
components: {
@@ -38,9 +48,8 @@ export default {
shouldShowCreateRunnerWorkflow() {
// create_runner_workflow_for_admin or create_runner_workflow_for_namespace
return (
- this.newRunnerPath &&
- (this.glFeatures?.createRunnerWorkflowForAdmin ||
- this.glFeatures?.createRunnerWorkflowForNamespace)
+ this.glFeatures?.createRunnerWorkflowForAdmin ||
+ this.glFeatures?.createRunnerWorkflowForNamespace
);
},
},
@@ -48,35 +57,59 @@ export default {
svgHeight: 145,
EMPTY_STATE_SVG_URL,
FILTERED_SVG_URL,
+
+ I18N_GET_STARTED,
+ I18N_RUNNERS_ARE_AGENTS,
+ I18N_CREATE_RUNNER_LINK,
+ I18N_STILL_USING_REGISTRATION_TOKENS,
+ I18N_CONTACT_ADMIN_TO_REGISTER,
+ I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
+ I18N_NO_RESULTS,
+ I18N_EDIT_YOUR_SEARCH,
};
</script>
<template>
<gl-empty-state
v-if="isSearchFiltered"
- :title="s__('Runners|No results found')"
+ :title="$options.I18N_NO_RESULTS"
:svg-path="$options.FILTERED_SVG_URL"
:svg-height="$options.svgHeight"
- :description="s__('Runners|Edit your search and try again')"
+ :description="$options.I18N_EDIT_YOUR_SEARCH"
/>
<gl-empty-state
v-else
- :title="s__('Runners|Get started with runners')"
+ :title="$options.I18N_GET_STARTED"
:svg-path="$options.EMPTY_STATE_SVG_URL"
:svg-height="$options.svgHeight"
>
- <template v-if="registrationToken" #description>
+ <template #description>
+ {{ $options.I18N_RUNNERS_ARE_AGENTS }}
+ <template v-if="shouldShowCreateRunnerWorkflow">
+ <gl-sprintf v-if="newRunnerPath" :message="$options.I18N_CREATE_RUNNER_LINK">
+ <template #link="{ content }">
+ <gl-link :href="newRunnerPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <template v-if="registrationToken">
+ <br />
+ <gl-link v-gl-modal="$options.modalId">{{
+ $options.I18N_STILL_USING_REGISTRATION_TOKENS
+ }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="registrationToken"
+ />
+ </template>
+ <template v-if="!newRunnerPath && !registrationToken">
+ {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
+ </template>
+ </template>
<gl-sprintf
- :message="
- s__(
- 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
- )
- "
+ v-else-if="registrationToken"
+ :message="$options.I18N_FOLLOW_REGISTRATION_INSTRUCTIONS"
>
- <template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }">
- <gl-link :href="newRunnerPath">{{ content }}</gl-link>
- </template>
- <template v-else #link="{ content }">
+ <template #link="{ content }">
<gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
<runner-instructions-modal
:modal-id="$options.modalId"
@@ -84,13 +117,9 @@ export default {
/>
</template>
</gl-sprintf>
- </template>
- <template v-else #description>
- {{
- s__(
- 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.',
- )
- }}
+ <template v-else>
+ {{ $options.I18N_CONTACT_ADMIN_TO_REGISTER }}
+ </template>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue b/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue
new file mode 100644
index 00000000000..d298d8ded82
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_managers_badge.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { formatNumber, s__, sprintf } from '~/locale';
+
+export default {
+ name: 'RunnerManagersBadge',
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ shouldShowBadge() {
+ // runner managers can be grouped, but this information is only shown
+ // when we have 2 or more.
+ return this.count >= 2;
+ },
+ formattedCount() {
+ return formatNumber(this.count);
+ },
+ tooltip() {
+ return sprintf(s__('Runners|%{count} runners in this group'), {
+ count: this.formattedCount,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-if="shouldShowBadge"
+ v-gl-tooltip="tooltip"
+ variant="muted"
+ icon="container-image"
+ v-bind="$attrs"
+ >
+ {{ formattedCount }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue b/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue
new file mode 100644
index 00000000000..5cc1bbef481
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_managers_detail.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlCollapse, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
+import { __, s__, formatNumber } from '~/locale';
+import { createAlert } from '~/alert';
+import runnerManagersQuery from '../graphql/show/runner_managers.query.graphql';
+import { I18N_FETCH_ERROR } from '../constants';
+import { captureException } from '../sentry_utils';
+import { tableField } from '../utils';
+import RunnerManagersTable from './runner_managers_table.vue';
+
+export default {
+ name: 'RunnerManagersDetail',
+ components: {
+ GlCollapse,
+ GlButton,
+ GlIcon,
+ GlSkeletonLoader,
+ RunnerManagersTable,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ validator: (runner) => {
+ return Boolean(runner?.id);
+ },
+ },
+ },
+ data() {
+ return {
+ skip: true,
+ expanded: false,
+ managers: [],
+ };
+ },
+ apollo: {
+ managers: {
+ query: runnerManagersQuery,
+ skip() {
+ return this.skip;
+ },
+ variables() {
+ return { runnerId: this.runner.id };
+ },
+ update({ runner }) {
+ return runner?.managers?.nodes || [];
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+ captureException({ error, component: this.$options.name });
+ },
+ },
+ },
+ computed: {
+ runnerManagersCount() {
+ return this.runner?.managers?.count || 0;
+ },
+ runnerManagersCountFormatted() {
+ return formatNumber(this.runnerManagersCount);
+ },
+ icon() {
+ return this.expanded ? 'chevron-down' : 'chevron-right';
+ },
+ text() {
+ return this.expanded ? __('Hide details') : __('Show details');
+ },
+ loading() {
+ return this.$apollo?.queries.managers.loading;
+ },
+ },
+ methods: {
+ fetchManagers() {
+ this.skip = false;
+ },
+ toggleExpanded() {
+ this.expanded = !this.expanded;
+ },
+ },
+ fields: [
+ tableField({ key: 'systemId', label: s__('Runners|System ID') }),
+ tableField({
+ key: 'contactedAt',
+ label: s__('Runners|Last contact'),
+ tdClass: ['gl-text-right'],
+ thClasses: ['gl-text-right'],
+ }),
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <gl-icon name="container-image" class="gl-text-secondary" />
+ {{ runnerManagersCountFormatted }}
+ <gl-button
+ v-if="runnerManagersCount"
+ variant="link"
+ @mouseover.once="fetchManagers"
+ @focus.once="fetchManagers"
+ @click.once="fetchManagers"
+ @click="toggleExpanded"
+ >
+ <gl-icon :name="icon" /> {{ text }}
+ </gl-button>
+
+ <gl-collapse :visible="expanded" class="gl-mt-5">
+ <gl-skeleton-loader v-if="loading" />
+ <runner-managers-table v-else-if="managers.length" :items="managers" />
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue
new file mode 100644
index 00000000000..10790c398b0
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlIntersperse, GlTableLite } from '@gitlab/ui';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { s__ } from '~/locale';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { tableField } from '../utils';
+import { I18N_STATUS_NEVER_CONTACTED } from '../constants';
+import RunnerStatusBadge from './runner_status_badge.vue';
+
+export default {
+ name: 'RunnerManagersTable',
+ components: {
+ GlTableLite,
+ TimeAgo,
+ HelpPopover,
+ GlIntersperse,
+ RunnerStatusBadge,
+ RunnerUpgradeStatusIcon: () =>
+ import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ fields: [
+ tableField({ key: 'systemId', label: s__('Runners|System ID') }),
+ tableField({ key: 'status', label: s__('Runners|Status') }),
+ tableField({ key: 'version', label: s__('Runners|Version') }),
+ tableField({ key: 'ipAddress', label: s__('Runners|IP Address') }),
+ tableField({ key: 'executorName', label: s__('Runners|Executor') }),
+ tableField({ key: 'architecturePlatform', label: s__('Runners|Arch/Platform') }),
+ tableField({
+ key: 'contactedAt',
+ label: s__('Runners|Last contact'),
+ tdClass: ['gl-text-right'],
+ thClasses: ['gl-text-right'],
+ }),
+ ],
+ I18N_STATUS_NEVER_CONTACTED,
+};
+</script>
+
+<template>
+ <gl-table-lite :fields="$options.fields" :items="items">
+ <template #head(systemId)="{ label }">
+ {{ label }}
+ <help-popover>
+ {{ s__('Runners|The unique ID for each runner that uses this configuration.') }}
+ </help-popover>
+ </template>
+ <template #cell(status)="{ item = {} }">
+ <runner-status-badge :contacted-at="item.contactedAt" :status="item.status" />
+ </template>
+ <template #cell(version)="{ item = {} }">
+ {{ item.version }}
+ <template v-if="item.revision">({{ item.revision }})</template>
+ <runner-upgrade-status-icon :upgrade-status="item.upgradeStatus" />
+ </template>
+ <template #cell(architecturePlatform)="{ item = {} }">
+ <gl-intersperse separator="/">
+ <span v-if="item.architectureName">{{ item.architectureName }}</span>
+ <span v-if="item.platformName">{{ item.platformName }}</span>
+ </gl-intersperse>
+ </template>
+ <template #cell(contactedAt)="{ item = {} }">
+ <template v-if="item.contactedAt">
+ <time-ago :time="item.contactedAt" />
+ </template>
+ <template v-else>{{ $options.I18N_STATUS_NEVER_CONTACTED }}</template>
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_name.vue b/app/assets/javascripts/ci/runner/components/runner_name.vue
index d4ecfd2d776..a877ff0f06c 100644
--- a/app/assets/javascripts/ci/runner/components/runner_name.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_name.vue
@@ -1,5 +1,5 @@
<script>
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { formatRunnerName } from '../utils';
export default {
props: {
@@ -8,13 +8,13 @@ export default {
required: true,
},
},
- methods: {
- getIdFromGraphQLId,
+ computed: {
+ name() {
+ return formatRunnerName(this.runner);
+ },
},
};
</script>
<template>
- <span class="gl-font-weight-bold gl-vertical-align-middle"
- >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span
- >
+ <span class="gl-font-weight-bold gl-vertical-align-middle">{{ name }}</span>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
index a27af232e97..d16c8f98bad 100644
--- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
+import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql';
import { createAlert } from '~/alert';
import { captureException } from '~/ci/runner/sentry_utils';
import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
@@ -31,14 +31,14 @@ export default {
};
},
computed: {
- isActive() {
- return this.runner.active;
+ isPaused() {
+ return this.runner.paused;
},
icon() {
- return this.isActive ? 'pause' : 'play';
+ return this.isPaused ? 'play' : 'pause';
},
label() {
- return this.isActive ? I18N_PAUSE : I18N_RESUME;
+ return this.isPaused ? I18N_RESUME : I18N_PAUSE;
},
buttonContent() {
if (this.compact) {
@@ -56,7 +56,7 @@ export default {
// Prevent a "sticky" tooltip: If this button is disabled,
// mouseout listeners don't run leaving the tooltip stuck
if (!this.updating) {
- return this.isActive ? I18N_PAUSE_TOOLTIP : I18N_RESUME_TOOLTIP;
+ return this.isPaused ? I18N_RESUME_TOOLTIP : I18N_PAUSE_TOOLTIP;
}
return '';
},
@@ -67,7 +67,7 @@ export default {
try {
const input = {
id: this.runner.id,
- active: !this.isActive,
+ paused: !this.isPaused,
};
const {
@@ -75,7 +75,7 @@ export default {
runnerUpdate: { errors },
},
} = await this.$apollo.mutate({
- mutation: runnerToggleActiveMutation,
+ mutation: runnerTogglePausedMutation,
variables: {
input,
},
diff --git a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue
index d084408781e..c2c52bd756a 100644
--- a/app/assets/javascripts/ci/runner/components/runner_status_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_status_badge.vue
@@ -26,21 +26,27 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- runner: {
- required: true,
- type: Object,
+ contactedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ status: {
+ type: String,
+ required: false,
+ default: null,
},
},
computed: {
contactedAtTimeAgo() {
- if (this.runner.contactedAt) {
- return getTimeago().format(this.runner.contactedAt);
+ if (this.contactedAt) {
+ return getTimeago().format(this.contactedAt);
}
// Prevent "just now" from being rendered, in case data is missing.
return __('never');
},
badge() {
- switch (this.runner?.status) {
+ switch (this.status) {
case STATUS_ONLINE:
return {
icon: 'status-active',
@@ -68,7 +74,7 @@ export default {
variant: 'warning',
label: I18N_STATUS_STALE,
// runner may have contacted (or not) and be stale: consider both cases.
- tooltip: this.runner.contactedAt
+ tooltip: this.contactedAt
? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP)
: I18N_STALE_NEVER_CONTACTED_TOOLTIP,
};
diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
index 2d34c551d6d..6b94e594f1c 100644
--- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
@@ -1,23 +1,16 @@
<script>
-import {
- GlButton,
- GlIcon,
- GlForm,
- GlFormCheckbox,
- GlFormGroup,
- GlFormInputGroup,
- GlSkeletonLoader,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlButton, GlForm } from '@gitlab/ui';
+import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { captureException } from '~/ci/runner/sentry_utils';
+
import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee_else_ce/ci/runner/runner_update_form_utils';
-import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { __ } from '~/locale';
-import { captureException } from '~/ci/runner/sentry_utils';
-import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
+import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants';
import runnerUpdateMutation from '../graphql/edit/runner_update.mutation.graphql';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
@@ -25,20 +18,11 @@ export default {
name: 'RunnerUpdateForm',
components: {
GlButton,
- GlIcon,
GlForm,
- GlFormCheckbox,
- GlFormGroup,
- GlFormInputGroup,
- GlSkeletonLoader,
- RunnerMaintenanceNoteField: () =>
- import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'),
+ RunnerFormFields,
RunnerUpdateCostFactorFields: () =>
import('ee_component/ci/runner/components/runner_update_cost_factor_fields.vue'),
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
runner: {
type: Object,
@@ -59,19 +43,17 @@ export default {
data() {
return {
saving: false,
- model: runnerToModel(this.runner),
+ model: null,
};
},
computed: {
- canBeLockedToProject() {
- return this.runner?.runnerType === PROJECT_TYPE;
+ runnerType() {
+ return this.runner?.runnerType;
},
},
watch: {
- runner(newVal, oldVal) {
- if (oldVal === null) {
- this.model = runnerToModel(newVal);
- }
+ runner(val) {
+ this.model = runnerToModel(val);
},
},
methods: {
@@ -101,7 +83,7 @@ export default {
},
onSuccess() {
saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS });
- redirectTo(this.runnerPath); // eslint-disable-line import/no-deprecated
+ visitUrl(this.runnerPath);
},
onError(message) {
this.saving = false;
@@ -114,99 +96,8 @@ export default {
</script>
<template>
<gl-form @submit.prevent="onSubmit">
- <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4>
-
- <gl-skeleton-loader v-if="loading" />
-
- <template v-else>
- <gl-form-group :label="__('Description')" data-testid="runner-field-description">
- <gl-form-input-group v-model="model.description" />
- </gl-form-group>
- <runner-maintenance-note-field v-model="model.maintenanceNote" />
- </template>
-
- <hr />
-
- <h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Configuration') }}</h4>
-
- <template v-if="loading">
- <gl-skeleton-loader v-for="i in 3" :key="i" />
- </template>
- <template v-else>
- <div class="gl-mb-5">
- <gl-form-checkbox
- v-model="model.active"
- data-testid="runner-field-paused"
- :value="false"
- :unchecked-value="true"
- >
- {{ __('Paused') }}
- <template #help>
- {{ s__('Runners|Stop the runner from accepting new jobs.') }}
- </template>
- </gl-form-checkbox>
-
- <gl-form-checkbox
- v-model="model.accessLevel"
- data-testid="runner-field-protected"
- :value="$options.ACCESS_LEVEL_REF_PROTECTED"
- :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
- >
- {{ __('Protected') }}
- <template #help>
- {{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
- </template>
- </gl-form-checkbox>
-
- <gl-form-checkbox v-model="model.runUntagged" data-testid="runner-field-run-untagged">
- {{ __('Run untagged jobs') }}
- <template #help>
- {{ s__('Runners|Use the runner for jobs without tags, in addition to tagged jobs.') }}
- </template>
- </gl-form-checkbox>
-
- <gl-form-checkbox
- v-if="canBeLockedToProject"
- v-model="model.locked"
- data-testid="runner-field-locked"
- >
- {{ __('Lock to current projects') }} <gl-icon name="lock" />
- <template #help>
- {{
- s__(
- 'Runners|Use the runner for the currently assigned projects only. Only administrators can change the assigned projects.',
- )
- }}
- </template>
- </gl-form-checkbox>
- </div>
-
- <gl-form-group
- data-testid="runner-field-max-timeout"
- :label="__('Maximum job timeout')"
- :description="
- s__(
- 'Runners|Enter the number of seconds. This timeout takes precedence over lower timeouts set for the project.',
- )
- "
- >
- <gl-form-input-group v-model.number="model.maximumTimeout" type="number" />
- </gl-form-group>
-
- <gl-form-group
- data-testid="runner-field-tags"
- :label="__('Tags')"
- :description="
- __(
- 'You can set up jobs to only use runners with specific tags. Separate tags with commas.',
- )
- "
- >
- <gl-form-input-group v-model="model.tagList" />
- </gl-form-group>
-
- <runner-update-cost-factor-fields v-model="model" />
- </template>
+ <runner-form-fields v-model="model" :loading="loading" />
+ <runner-update-cost-factor-fields v-model="model" :runner-type="runnerType" />
<div class="gl-mt-6">
<gl-button
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 4e36a410a66..40841696ead 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -9,7 +9,9 @@ export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
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 I18N_CREATE_ERROR = s__(
+ 'Runners|An error occurred while creating the runner. Please try again.',
+);
export const FILTER_CSS_CLASSES =
'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1';
@@ -103,6 +105,26 @@ export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{ava
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
export const I18N_ADMIN = s__('Runners|Administrator');
+// No runners registered
+export const I18N_GET_STARTED = s__('Runners|Get started with runners');
+export const I18N_RUNNERS_ARE_AGENTS = s__(
+ 'Runners|Runners are the agents that run your CI/CD jobs.',
+);
+export const I18N_CREATE_RUNNER_LINK = s__(
+ 'Runners|%{linkStart}Create a new runner%{linkEnd} to get started.',
+);
+export const I18N_STILL_USING_REGISTRATION_TOKENS = s__('Runners|Still using registration tokens?');
+export const I18N_CONTACT_ADMIN_TO_REGISTER = s__(
+ 'Runners|To register new runners, contact your administrator.',
+);
+export const I18N_FOLLOW_REGISTRATION_INSTRUCTIONS = s__(
+ 'Runners|Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
+);
+
+// No runners found
+export const I18N_NO_RESULTS = s__('Runners|No results found');
+export const I18N_EDIT_YOUR_SEARCH = s__('Runners|Edit your search and try again');
+
// Runner details
export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path
@@ -256,3 +278,5 @@ export const SERVICE_COMMANDS_HELP_URL =
export const CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md';
export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
+export const RUNNER_MANAGERS_HELP_URL =
+ 'https://docs.gitlab.com/runner/fleet_scaling/#workers-executors-and-autoscaling-capabilities';
diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
index d18b80511fb..41ec9967d90 100644
--- a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
@@ -2,7 +2,7 @@ fragment RunnerFieldsShared on CiRunner {
id
shortSha
runnerType
- active
+ paused
accessLevel
runUntagged
locked
diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index 4eebcd01be6..c0b888e758b 100644
--- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
@@ -7,7 +7,7 @@ fragment ListItemShared on CiRunner {
shortSha
version
ipAddress
- active
+ paused
locked
jobCount
tagList
@@ -22,6 +22,9 @@ fragment ListItemShared on CiRunner {
updateRunner
deleteRunner
}
+ managers {
+ count
+ }
groups(first: 1) {
nodes {
id
diff --git a/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql
index 9b15570dbc0..e862a20750f 100644
--- a/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql
@@ -1,11 +1,11 @@
# Mutation executed for the pause/resume button in the
# runner list and details views.
-mutation runnerToggleActive($input: RunnerUpdateInput!) {
+mutation runnerTogglePaused($input: RunnerUpdateInput!) {
runnerUpdate(input: $input) {
runner {
id
- active
+ paused
}
errors
}
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
index bd53fb29bd0..1a2ad59650e 100644
--- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -2,7 +2,7 @@ fragment RunnerDetailsShared on CiRunner {
id
shortSha
runnerType
- active
+ paused
accessLevel
runUntagged
locked
@@ -20,6 +20,9 @@ fragment RunnerDetailsShared on CiRunner {
tokenExpiresAt
version
editAdminUrl
+ managers {
+ count
+ }
userPermissions {
updateRunner
deleteRunner
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql
new file mode 100644
index 00000000000..b630786b3d5
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager.fragment.graphql
@@ -0,0 +1,5 @@
+#import "./runner_manager_shared.fragment.graphql"
+
+fragment CiRunnerManager on CiRunnerManager {
+ ...CiRunnerManagerShared
+}
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql
new file mode 100644
index 00000000000..ead005d1252
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql
@@ -0,0 +1,12 @@
+fragment CiRunnerManagerShared on CiRunnerManager {
+ id
+ systemId
+ status
+ version
+ revision
+ executorName
+ architectureName
+ platformName
+ ipAddress
+ contactedAt
+}
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql
new file mode 100644
index 00000000000..cc16267e619
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_managers.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/ci/runner/graphql/show/runner_manager.fragment.graphql"
+
+query getRunnerManagers($runnerId: CiRunnerID!) {
+ runner(id: $runnerId) {
+ id
+ managers {
+ count
+ nodes {
+ ...CiRunnerManager
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
index 67d29daf66f..2e1706ddae9 100644
--- a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
@@ -1,6 +1,6 @@
<script>
import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
@@ -38,7 +38,7 @@ export default {
message: s__('Runners|Runner created.'),
variant: VARIANT_SUCCESS,
});
- redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ visitUrl(ephemeralRegisterUrl);
},
onError(error) {
createAlert({ message: error.message });
@@ -66,7 +66,7 @@ export default {
<hr aria-hidden="true" />
- <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ <h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Platform') }}
</h2>
<runner-platforms-radio-group v-model="platform" />
diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index 1318bf5a2e6..e885cf45c5a 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
@@ -2,7 +2,7 @@
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -76,7 +76,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
+ visitUrl(this.runnersPath);
},
},
};
diff --git a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
index f0ae54c0232..51f5a9ce8d9 100644
--- a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
@@ -1,6 +1,6 @@
<script>
import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
@@ -38,7 +38,7 @@ export default {
message: s__('Runners|Runner created.'),
variant: VARIANT_SUCCESS,
});
- redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ visitUrl(ephemeralRegisterUrl);
},
onError(error) {
createAlert({ message: error.message });
@@ -66,7 +66,7 @@ export default {
<hr aria-hidden="true" />
- <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ <h2 class="gl-font-size-h2 gl-my-5">
{{ s__('Runners|Platform') }}
</h2>
<runner-platforms-radio-group v-model="platform" />
diff --git a/app/assets/javascripts/ci/runner/runner_update_form_utils.js b/app/assets/javascripts/ci/runner/runner_update_form_utils.js
index 3b519fa7d71..6f6c9f64af0 100644
--- a/app/assets/javascripts/ci/runner/runner_update_form_utils.js
+++ b/app/assets/javascripts/ci/runner/runner_update_form_utils.js
@@ -4,7 +4,7 @@ export const runnerToModel = (runner) => {
description,
maximumTimeout,
accessLevel,
- active,
+ paused,
locked,
runUntagged,
tagList = [],
@@ -15,7 +15,7 @@ export const runnerToModel = (runner) => {
description,
maximumTimeout,
accessLevel,
- active,
+ paused,
locked,
runUntagged,
tagList: tagList.join(', '),
diff --git a/app/assets/javascripts/ci/runner/utils.js b/app/assets/javascripts/ci/runner/utils.js
index 1ca0a9e86b5..bb1ffca62ee 100644
--- a/app/assets/javascripts/ci/runner/utils.js
+++ b/app/assets/javascripts/ci/runner/utils.js
@@ -1,4 +1,5 @@
import { formatNumber } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { RUNNER_JOB_COUNT_LIMIT } from './constants';
/**
@@ -81,3 +82,13 @@ export const getPaginationVariables = (pagination, pageSize = 10) => {
export const parseInterval = (interval) => {
return typeof interval === 'string' ? parseInt(interval, 10) : null;
};
+
+/**
+ * Creates formatted runner name
+ *
+ * @param {Object} runner - Runner object
+ * @returns Formatted name
+ */
+export const formatRunnerName = ({ id, shortSha }) => {
+ return `#${getIdFromGraphQLId(id)} (${shortSha})`;
+};
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index d7e98638a11..529be7169db 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -7,6 +7,8 @@ import {
GlTooltip,
GlTooltipDirective,
GlPopover,
+ GlBadge,
+ GlPagination,
} from '@gitlab/ui';
import semverLt from 'semver/functions/lt';
import semverInc from 'semver/functions/inc';
@@ -15,7 +17,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
+import { MAX_LIST_COUNT, AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
import DeleteAgentButton from './delete_agent_button.vue';
@@ -28,6 +30,8 @@ export default {
GlSprintf,
GlTooltip,
GlPopover,
+ GlBadge,
+ GlPagination,
TimeAgoTooltip,
DeleteAgentButton,
},
@@ -60,6 +64,12 @@ export default {
type: Number,
},
},
+ data() {
+ return {
+ currentPage: 1,
+ limit: this.maxAgents ?? MAX_LIST_COUNT,
+ };
+ },
computed: {
fields() {
const tdClass = 'gl-pt-3! gl-pb-4! gl-vertical-align-middle!';
@@ -114,6 +124,16 @@ export default {
serverVersion() {
return this.kasVersion || this.gitlabVersion;
},
+ showPagination() {
+ return !this.maxAgents && this.agents.length > this.limit;
+ },
+ prevPage() {
+ return Math.max(this.currentPage - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.currentPage + 1;
+ return nextPage > Math.ceil(this.agents.length / this.limit) ? null : nextPage;
+ },
},
methods: {
getStatusCellId(item) {
@@ -184,84 +204,105 @@ export default {
</script>
<template>
- <gl-table
- :items="agentsList"
- :fields="fields"
- stacked="md"
- class="gl-mb-4!"
- data-testid="cluster-agent-list-table"
- >
- <template #cell(name)="{ item }">
- <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
- {{ item.name }}
- </gl-link>
- </template>
+ <div>
+ <gl-table
+ :items="agentsList"
+ :fields="fields"
+ :per-page="limit"
+ :current-page="currentPage"
+ stacked="md"
+ class="gl-mb-4!"
+ data-testid="cluster-agent-list-table"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">{{ item.name }}</gl-link
+ ><gl-badge v-if="item.isShared" class="gl-ml-3">{{
+ $options.i18n.sharedBadgeText
+ }}</gl-badge>
+ </template>
- <template #cell(status)="{ item }">
- <span
- :id="getStatusCellId(item)"
- class="gl-md-pr-5"
- data-testid="cluster-agent-connection-status"
- >
- <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
- <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span
- >{{ $options.AGENT_STATUSES[item.status].name }}
- </span>
- <gl-tooltip v-if="item.status === 'active'" :target="getStatusCellId(item)" placement="right">
- <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
- ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
- </gl-sprintf>
- </gl-tooltip>
- <gl-popover
- v-else
- :target="getStatusCellId(item)"
- :title="$options.AGENT_STATUSES[item.status].tooltip.title"
- placement="right"
- container="viewport"
- >
- <p>
- <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
- ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
- >
- </p>
- <p class="gl-mb-0">
- <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm">
- {{ $options.i18n.troubleshootingText }}</gl-link
- >
- </p>
- </gl-popover>
- </template>
+ <template #cell(status)="{ item }">
+ <span
+ :id="getStatusCellId(item)"
+ class="gl-md-pr-5"
+ data-testid="cluster-agent-connection-status"
+ >
+ <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="16" /></span
+ >{{ $options.AGENT_STATUSES[item.status].name }}
+ </span>
+ <gl-tooltip
+ v-if="item.status === 'active'"
+ :target="getStatusCellId(item)"
+ placement="right"
+ >
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
+ </gl-sprintf>
+ </gl-tooltip>
+ <gl-popover
+ v-else
+ :target="getStatusCellId(item)"
+ :title="$options.AGENT_STATUSES[item.status].tooltip.title"
+ placement="right"
+ container="viewport"
+ >
+ <p>
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
+ >
+ </p>
+ <p class="gl-mb-0">
+ <gl-link :href="$options.troubleshootingLink" target="_blank" class="gl-font-sm">
+ {{ $options.i18n.troubleshootingText }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
+ <template #cell(lastContact)="{ item }">
+ <span data-testid="cluster-agent-last-contact">
+ <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
+ <span v-else>{{ $options.i18n.neverConnectedText }}</span>
+ </span>
+ </template>
- <template #cell(lastContact)="{ item }">
- <span data-testid="cluster-agent-last-contact">
- <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
- <span v-else>{{ $options.i18n.neverConnectedText }}</span>
- </span>
- </template>
+ <template #cell(version)="{ item }">
+ <span :id="getVersionCellId(item)" data-testid="cluster-agent-version">
+ {{ getAgentVersionString(item) }}
- <template #cell(version)="{ item }">
- <span :id="getVersionCellId(item)" data-testid="cluster-agent-version">
- {{ getAgentVersionString(item) }}
+ <gl-icon
+ v-if="isVersionMismatch(item) || isVersionOutdated(item)"
+ name="warning"
+ class="gl-text-orange-500 gl-ml-2"
+ />
+ </span>
- <gl-icon
+ <gl-popover
v-if="isVersionMismatch(item) || isVersionOutdated(item)"
- name="warning"
- class="gl-text-orange-500 gl-ml-2"
- />
- </span>
+ :target="getVersionCellId(item)"
+ :title="getVersionPopoverTitle(item)"
+ :data-testid="getPopoverTestId(item)"
+ placement="right"
+ container="viewport"
+ >
+ <div v-if="isVersionMismatch(item) && isVersionOutdated(item)">
+ <p>{{ $options.i18n.versionMismatchText }}</p>
- <gl-popover
- v-if="isVersionMismatch(item) || isVersionOutdated(item)"
- :target="getVersionCellId(item)"
- :title="getVersionPopoverTitle(item)"
- :data-testid="getPopoverTestId(item)"
- placement="right"
- container="viewport"
- >
- <div v-if="isVersionMismatch(item) && isVersionOutdated(item)">
- <p>{{ $options.i18n.versionMismatchText }}</p>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.versionOutdatedText">
+ <template #version>{{ serverVersion }}</template>
+ </gl-sprintf>
+ <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
+ {{ $options.i18n.viewDocsText }}</gl-link
+ >
+ </p>
+ </div>
+ <p v-else-if="isVersionMismatch(item)" class="gl-mb-0">
+ {{ $options.i18n.versionMismatchText }}
+ </p>
- <p class="gl-mb-0">
+ <p v-else-if="isVersionOutdated(item)" class="gl-mb-0">
<gl-sprintf :message="$options.i18n.versionOutdatedText">
<template #version>{{ serverVersion }}</template>
</gl-sprintf>
@@ -269,53 +310,54 @@ export default {
{{ $options.i18n.viewDocsText }}</gl-link
>
</p>
- </div>
- <p v-else-if="isVersionMismatch(item)" class="gl-mb-0">
- {{ $options.i18n.versionMismatchText }}
- </p>
+ </gl-popover>
+ </template>
- <p v-else-if="isVersionOutdated(item)" class="gl-mb-0">
- <gl-sprintf :message="$options.i18n.versionOutdatedText">
- <template #version>{{ serverVersion }}</template>
- </gl-sprintf>
- <gl-link :href="$options.versionUpdateLink" class="gl-font-sm">
- {{ $options.i18n.viewDocsText }}</gl-link
- >
- </p>
- </gl-popover>
- </template>
+ <template #cell(agentID)="{ item }">
+ <span data-testid="cluster-agent-id">
+ {{ getAgentId(item) }}
+ </span>
+ </template>
- <template #cell(agentID)="{ item }">
- <span data-testid="cluster-agent-id">
- {{ getAgentId(item) }}
- </span>
- </template>
+ <template #cell(configuration)="{ item }">
+ <span data-testid="cluster-agent-configuration-link">
+ <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
+ {{ getAgentConfigPath(item.name) }}
+ </gl-link>
- <template #cell(configuration)="{ item }">
- <span data-testid="cluster-agent-configuration-link">
- <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
- {{ getAgentConfigPath(item.name) }}
- </gl-link>
+ <span v-else-if="item.isShared">
+ {{ $options.i18n.externalConfigText }}
+ </span>
- <span v-else
- >{{ $options.i18n.defaultConfigText }}
- <gl-link
- v-gl-tooltip
- :href="$options.configHelpLink"
- :title="$options.i18n.defaultConfigTooltip"
- :aria-label="$options.i18n.defaultConfigTooltip"
- class="gl-vertical-align-middle"
- ><gl-icon name="question-o" :size="14" /></gl-link
- ></span>
- </span>
- </template>
+ <span v-else
+ >{{ $options.i18n.defaultConfigText }}
+ <gl-link
+ v-gl-tooltip
+ :href="$options.configHelpLink"
+ :title="$options.i18n.defaultConfigTooltip"
+ :aria-label="$options.i18n.defaultConfigTooltip"
+ class="gl-vertical-align-middle"
+ ><gl-icon name="question-o" :size="14" /></gl-link
+ ></span>
+ </span>
+ </template>
+
+ <template #cell(options)="{ item }">
+ <delete-agent-button
+ v-if="!item.isShared"
+ :agent="item"
+ :default-branch-name="defaultBranchName"
+ />
+ </template>
+ </gl-table>
- <template #cell(options)="{ item }">
- <delete-agent-button
- :agent="item"
- :default-branch-name="defaultBranchName"
- :max-agents="maxAgents"
- />
- </template>
- </gl-table>
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-mt-5"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 36f0f8e61ba..b1765d336c8 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -1,9 +1,9 @@
<script>
-import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { MAX_LIST_COUNT, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants';
+import { AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import { getAgentLastContact, getAgentStatus } from '../clusters_util';
import AgentEmptyState from './agent_empty_state.vue';
@@ -27,7 +27,6 @@ export default {
return {
defaultBranchName: this.defaultBranchName,
projectPath: this.projectPath,
- ...this.cursor,
};
},
update(data) {
@@ -37,13 +36,15 @@ export default {
result() {
this.emitAgentsLoaded();
},
+ error() {
+ this.queryErrored = true;
+ },
},
},
components: {
AgentEmptyState,
AgentTable,
GlAlert,
- GlKeysetPagination,
GlLoadingIcon,
GlBanner,
LocalStorageSync,
@@ -69,41 +70,41 @@ export default {
},
data() {
return {
- cursor: {
- first: this.limit ? this.limit : MAX_LIST_COUNT,
- last: null,
- },
folderList: {},
feedbackBannerDismissed: false,
+ queryErrored: false,
};
},
computed: {
agentList() {
- let list = this.agents?.project?.clusterAgents?.nodes;
+ const localAgents = this.agents?.project?.clusterAgents?.nodes || [];
+ const sharedAgents = [
+ ...(this.agents?.project?.ciAccessAuthorizedAgents?.nodes || []),
+ ...(this.agents?.project?.userAccessAuthorizedAgents?.nodes || []),
+ ].map((node) => {
+ return {
+ ...node.agent,
+ isShared: true,
+ };
+ });
- if (list) {
- list = list.map((agent) => {
+ const filteredList = [...localAgents, ...sharedAgents]
+ .filter((node, index, list) => {
+ return node && index === list.findIndex((agent) => agent.id === node.id);
+ })
+ .map((agent) => {
const configFolder = this.folderList[agent.name];
const lastContact = getAgentLastContact(agent?.tokens?.nodes);
const status = getAgentStatus(lastContact);
return { ...agent, configFolder, lastContact, status };
- });
- }
+ })
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
- return list;
- },
- agentPageInfo() {
- return this.agents?.project?.clusterAgents?.pageInfo || {};
+ return filteredList;
},
isLoading() {
return this.$apollo.queries.agents.loading;
},
- showPagination() {
- return !this.limit && (this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage);
- },
- treePageInfo() {
- return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
- },
feedbackBannerEnabled() {
return this.glFeatures.showGitlabAgentFeedback;
},
@@ -112,22 +113,6 @@ export default {
},
},
methods: {
- nextPage() {
- this.cursor = {
- first: MAX_LIST_COUNT,
- last: null,
- afterAgent: this.agentPageInfo.endCursor,
- afterTree: this.treePageInfo.endCursor,
- };
- },
- prevPage() {
- this.cursor = {
- first: null,
- last: MAX_LIST_COUNT,
- beforeAgent: this.agentPageInfo.startCursor,
- beforeTree: this.treePageInfo.endCursor,
- };
- },
updateTreeList(data) {
const configFolders = data?.project?.repository?.tree?.trees?.nodes;
@@ -138,8 +123,7 @@ export default {
}
},
emitAgentsLoaded() {
- const count = this.agents?.project?.clusterAgents?.count;
- this.$emit('onAgentsLoad', count);
+ this.$emit('onAgentsLoad', this.agentList?.length);
},
handleBannerClose() {
this.feedbackBannerDismissed = true;
@@ -151,7 +135,7 @@ export default {
<template>
<gl-loading-icon v-if="isLoading" size="lg" />
- <section v-else-if="agentList">
+ <section v-else-if="!queryErrored">
<div v-if="agentList.length">
<local-storage-sync
v-if="feedbackBannerEnabled"
@@ -174,12 +158,8 @@ export default {
<agent-table
:agents="agentList"
:default-branch-name="defaultBranchName"
- :max-agents="cursor.first"
+ :max-agents="limit"
/>
-
- <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
- </div>
</div>
<agent-empty-state v-else />
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
index 365e0384d87..75850cbb108 100644
--- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -58,8 +58,6 @@ export default {
selectAgent(agent) {
this.$emit('agentSelected', agent);
this.selectedAgent = agent;
-
- this.$refs.dropdown.closeAndFocus();
},
onKeyEnter() {
if (!this.searchTerm?.length) {
@@ -76,7 +74,6 @@ export default {
<template>
<div @keydown.enter.stop.prevent="onKeyEnter">
<gl-collapsible-listbox
- ref="dropdown"
v-model="selectedAgent"
class="gl-w-full"
toggle-class="select-agent-dropdown"
diff --git a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index 913db87f019..4088d5c79f7 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -39,11 +39,6 @@ export default {
required: false,
type: String,
},
- maxAgents: {
- default: null,
- required: false,
- type: Number,
- },
},
data() {
return {
@@ -64,8 +59,6 @@ export default {
getAgentsQueryVariables() {
return {
defaultBranchName: this.defaultBranchName,
- first: this.maxAgents,
- last: null,
projectPath: this.projectPath,
};
},
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 444b9ac2a14..55e62d1c698 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -13,10 +13,9 @@ import {
MODAL_TYPE_EMPTY,
MODAL_TYPE_REGISTER,
} from '../constants';
-import { addAgentToStore, addAgentConfigToStore } from '../graphql/cache_update';
+import { addAgentConfigToStore } from '../graphql/cache_update';
import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
-import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
import AvailableAgentsDropdown from './available_agents_dropdown.vue';
import AgentToken from './agent_token.vue';
@@ -148,14 +147,6 @@ export default {
projectPath: this.projectPath,
},
},
- update: (store, { data: { createClusterAgent } }) => {
- addAgentToStore(
- store,
- createClusterAgent,
- getAgentsQuery,
- this.getAgentsQueryVariables,
- );
- },
})
.then(({ data: { createClusterAgent } }) => {
return createClusterAgent;
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index fe3fa22fea3..3ce10f7c3a2 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,7 +1,7 @@
import { __, s__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
-export const MAX_LIST_COUNT = 25;
+export const MAX_LIST_COUNT = 20;
export const INSTALL_AGENT_MODAL_ID = 'install-agent';
export const ACTIVE_CONNECTION_TIME = 480000;
export const NAME_MAX_LENGTH = 50;
@@ -86,6 +86,8 @@ export const I18N_AGENT_TABLE = {
viewDocsText: s__('ClusterAgents|How to update an agent?'),
defaultConfigText: s__('ClusterAgents|Default configuration'),
defaultConfigTooltip: s__('ClusterAgents|What is default configuration?'),
+ sharedBadgeText: s__('ClusterAgents|shared'),
+ externalConfigText: s__('ClusterAgents|External project'),
};
export const I18N_AGENT_TOKEN = {
diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js
index e68f6a378c0..1c58652744d 100644
--- a/app/assets/javascripts/clusters_list/graphql/cache_update.js
+++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js
@@ -2,27 +2,6 @@ import produce from 'immer';
export const hasErrors = ({ errors = [] }) => errors?.length;
-export function addAgentToStore(store, createClusterAgent, query, variables) {
- if (!hasErrors(createClusterAgent)) {
- const { clusterAgent } = createClusterAgent;
- const sourceData = store.readQuery({
- query,
- variables,
- });
-
- const data = produce(sourceData, (draftData) => {
- draftData.project.clusterAgents.nodes.push(clusterAgent);
- draftData.project.clusterAgents.count += 1;
- });
-
- store.writeQuery({
- query,
- variables,
- data,
- });
- }
-}
-
export function addAgentConfigToStore(
store,
clusterAgentTokenCreate,
@@ -65,7 +44,12 @@ export function removeAgentFromStore(store, deleteClusterAgent, query, variables
draftData.project.clusterAgents.nodes = draftData.project.clusterAgents.nodes.filter(
({ id }) => id !== deleteClusterAgent.id,
);
- draftData.project.clusterAgents.count -= 1;
+ draftData.project.ciAccessAuthorizedAgents.nodes = draftData.project.ciAccessAuthorizedAgents.nodes.filter(
+ ({ agent }) => agent.id !== deleteClusterAgent.id,
+ );
+ draftData.project.userAccessAuthorizedAgents.nodes = draftData.project.userAccessAuthorizedAgents.nodes.filter(
+ ({ agent }) => agent.id !== deleteClusterAgent.id,
+ );
});
store.writeQuery({
diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
index 05d2525ab98..31897b50407 100644
--- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql
@@ -2,6 +2,7 @@ fragment ClusterAgentFragment on ClusterAgent {
id
name
webPath
+ createdAt
connections {
nodes {
metadata {
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
index 76920a0aef4..2a4f7b42eff 100644
--- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -1,26 +1,28 @@
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
#import "../fragments/cluster_agent.fragment.graphql"
-query getAgents(
- $defaultBranchName: String!
- $projectPath: ID!
- $first: Int
- $last: Int
- $afterAgent: String
- $beforeAgent: String
-) {
+query getAgents($defaultBranchName: String!, $projectPath: ID!) {
project(fullPath: $projectPath) {
id
- clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
+ clusterAgents {
nodes {
...ClusterAgentFragment
}
+ }
- pageInfo {
- ...PageInfo
+ ciAccessAuthorizedAgents {
+ nodes {
+ agent {
+ ...ClusterAgentFragment
+ }
}
+ }
- count
+ userAccessAuthorizedAgents {
+ nodes {
+ agent {
+ ...ClusterAgentFragment
+ }
+ }
}
repository {
diff --git a/app/assets/javascripts/code_review/signals.js b/app/assets/javascripts/code_review/signals.js
index 101b7996bb5..080879f4e1d 100644
--- a/app/assets/javascripts/code_review/signals.js
+++ b/app/assets/javascripts/code_review/signals.js
@@ -21,6 +21,11 @@ async function observeMergeRequestFinishingPreparation({ apollo, signaler }) {
query: getMr,
variables: { projectPath, iid },
});
+
+ if (!currentStatus.data.project) {
+ return;
+ }
+
const { id: gqlMrId, preparedAt } = currentStatus.data.project.mergeRequest;
let preparationObservable;
let preparationSubscriber;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index f2dac15a99e..8c8293eb09e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -41,7 +41,7 @@ export default {
viewType: {
type: String,
required: false,
- default: 'child',
+ default: 'root',
},
canCreatePipelineInTargetProject: {
type: Boolean,
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
index 7c06417e6b3..ce5b566ba20 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -43,7 +43,8 @@ export default {
this.$emit('hidden', ...args);
this.menuVisible = false;
},
- appendTo: () => document.body,
+ strategy: 'fixed',
+ maxWidth: 'auto',
},
}),
);
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue
new file mode 100644
index 00000000000..900164fe60f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue
@@ -0,0 +1,218 @@
+<script>
+import {
+ GlTooltipDirective as GlTooltip,
+ GlButton,
+ GlButtonGroup,
+ GlCollapsibleListbox,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import Reference from '../../extensions/reference';
+import ReferenceLabel from '../../extensions/reference_label';
+import EditorStateObserver from '../editor_state_observer.vue';
+import BubbleMenu from './bubble_menu.vue';
+
+const REFERENCE_NODE_TYPES = [Reference.name, ReferenceLabel.name];
+
+export default {
+ components: {
+ BubbleMenu,
+ EditorStateObserver,
+ GlButton,
+ GlCollapsibleListbox,
+ GlButtonGroup,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor', 'contentEditor'],
+ data() {
+ return {
+ nodeType: null,
+
+ referenceType: null,
+ originalText: null,
+
+ href: null,
+ text: null,
+ expandedText: null,
+ fullyExpandedText: null,
+
+ selectedTextFormat: {},
+
+ loading: false,
+ };
+ },
+ computed: {
+ isIssue() {
+ return this.referenceType === 'issue';
+ },
+ isMergeRequest() {
+ return this.referenceType === 'merge_request';
+ },
+ isEpic() {
+ return this.referenceType === 'epic';
+ },
+ isExpandable() {
+ return this.isIssue || this.isMergeRequest || this.isEpic;
+ },
+ textFormats() {
+ return [
+ {
+ value: '',
+ text: this.$options.i18n.referenceId[this.referenceType],
+ matcher: (text) => !text.endsWith('+') && !text.endsWith('+s'),
+ getText: () => this.text,
+ shouldShow: true,
+ },
+ {
+ value: '+',
+ text: this.$options.i18n.referenceTitle[this.referenceType],
+ matcher: (text) => text.endsWith('+'),
+ getText: () => this.expandedText,
+ shouldShow: true,
+ },
+ {
+ value: '+s',
+ text: this.$options.i18n.referenceSummary[this.referenceType],
+ matcher: (text) => text.endsWith('+s'),
+ getText: () => this.fullyExpandedText,
+ shouldShow: this.isIssue || this.isMergeRequest,
+ },
+ ];
+ },
+ },
+ methods: {
+ shouldShow: ({ editor }) => {
+ return REFERENCE_NODE_TYPES.some((type) => editor.isActive(type));
+ },
+ async updateReferenceInfoToState() {
+ this.nodeType = REFERENCE_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type));
+ if (!this.nodeType) return;
+
+ const {
+ referenceType,
+ href,
+ originalText,
+ text: alternateText,
+ } = this.tiptapEditor.getAttributes(this.nodeType);
+
+ this.href = href;
+ this.referenceType = referenceType;
+ this.originalText = originalText || alternateText;
+ this.selectedTextFormat = this.textFormats.find(({ matcher }) => matcher(this.originalText));
+
+ this.loading = true;
+
+ const { text, expandedText, fullyExpandedText } = await this.contentEditor.resolveReference(
+ this.originalText,
+ );
+
+ this.text = text;
+ this.expandedText = expandedText;
+ this.fullyExpandedText = fullyExpandedText;
+
+ this.loading = false;
+ },
+ removeReference() {
+ this.tiptapEditor.chain().focus().deleteSelection().run();
+ },
+ copyReferenceURL() {
+ navigator.clipboard.writeText(this.href);
+ },
+ applyFormat(value) {
+ const format = this.textFormats.find((v) => v.value === value);
+
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .updateAttributes(this.nodeType, {
+ text: format.getText(),
+ originalText: `${this.originalText.replace(/(\+|\+s)$/, '')}${format.value}`,
+ })
+ .run();
+
+ this.selectedTextFormat = format;
+ },
+ },
+ tippyOptions: {
+ placement: 'bottom',
+ },
+ i18n: {
+ referenceId: {
+ issue: __('Issue ID'),
+ merge_request: __('Merge request ID'),
+ epic: __('Epic ID'),
+ },
+ referenceTitle: {
+ issue: __('Issue title'),
+ merge_request: __('Merge request title'),
+ epic: __('Epic title'),
+ },
+ referenceSummary: {
+ issue: __('Issue summary'),
+ merge_request: __('Merge request summary'),
+ epic: __('Epic summary'),
+ },
+ copyURLLabel: {
+ issue: __('Copy issue URL'),
+ merge_request: __('Copy merge request URL'),
+ epic: __('Copy epic URL'),
+ },
+ removeLabel: {
+ issue: __('Remove issue reference'),
+ merge_request: __('Remove merge request reference'),
+ epic: __('Remove epic reference'),
+ },
+ },
+};
+</script>
+<template>
+ <editor-state-observer :debounce="0" @transaction="updateReferenceInfoToState">
+ <bubble-menu
+ v-show="isExpandable"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuReference"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ >
+ <gl-button-group class="gl-display-flex gl-align-items-center">
+ <span class="gl-py-2 gl-px-3 gl-text-secondary gl-white-space-nowrap">
+ {{ __('Display as:') }}
+ </span>
+ <gl-collapsible-listbox
+ v-show="!loading"
+ category="tertiary"
+ boundary="viewport"
+ :selected="selectedTextFormat.value"
+ :items="textFormats"
+ :loading="loading"
+ :toggle-text="selectedTextFormat.text"
+ toggle-class="gl-rounded-0!"
+ @select="applyFormat"
+ />
+ <gl-button
+ v-gl-tooltip.bottom
+ variant="default"
+ category="tertiary"
+ size="medium"
+ data-testid="copy-reference-url"
+ :aria-label="$options.i18n.copyURLLabel[referenceType]"
+ :title="$options.i18n.copyURLLabel[referenceType]"
+ icon="copy-to-clipboard"
+ @click="copyReferenceURL"
+ />
+ <gl-button
+ v-gl-tooltip.bottom
+ variant="default"
+ category="tertiary"
+ size="medium"
+ data-testid="remove-reference"
+ :aria-label="$options.i18n.removeLabel[referenceType]"
+ :title="$options.i18n.removeLabel[referenceType]"
+ icon="remove"
+ @click="removeReference"
+ />
+ </gl-button-group>
+ </bubble-menu>
+ </editor-state-observer>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 4c5bbca4110..92f3c3fb8fa 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -11,6 +11,7 @@ import EditorStateObserver from './editor_state_observer.vue';
import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue';
import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue';
import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue';
+import ReferenceBubbleMenu from './bubble_menus/reference_bubble_menu.vue';
import FormattingToolbar from './formatting_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -27,6 +28,7 @@ export default {
LinkBubbleMenu,
MediaBubbleMenu,
EditorStateObserver,
+ ReferenceBubbleMenu,
},
props: {
renderMarkdown: {
@@ -88,6 +90,11 @@ export default {
required: false,
default: () => ({}),
},
+ disableAttachments: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -221,37 +228,41 @@ export default {
class="md-area gl-border-none! gl-shadow-none!"
:class="{ 'is-focused': focused }"
>
- <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" />
- <div class="gl-relative">
- <code-block-bubble-menu />
- <link-bubble-menu />
- <media-bubble-menu />
- <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4">
- {{ placeholder }}
- </div>
- <tiptap-editor-content
- class="md gl-px-5"
- data-testid="content_editor_editablebox"
- :editor="contentEditor.tiptapEditor"
- />
- <loading-indicator v-if="isLoading" />
- <div
- v-if="quickActionsDocsPath"
- class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary"
- >
- <div class="gl-w-full gl-line-height-32 gl-font-sm">
- <gl-sprintf :message="$options.i18n.quickActionsText">
- <template #keyboard="{ content }">
- <kbd>{{ content }}</kbd>
- </template>
- <template #quickActionsDocsLink="{ content }">
- <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
- </div>
+ <formatting-toolbar
+ ref="toolbar"
+ :hide-attachment-button="disableAttachments"
+ @enableMarkdownEditor="$emit('enableMarkdownEditor')"
+ />
+ <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4">
+ {{ placeholder }}
+ </div>
+ <tiptap-editor-content
+ class="md gl-px-5"
+ data-testid="content_editor_editablebox"
+ :editor="contentEditor.tiptapEditor"
+ />
+ <loading-indicator v-if="isLoading" />
+
+ <code-block-bubble-menu />
+ <link-bubble-menu />
+ <media-bubble-menu />
+ <reference-bubble-menu />
+ </div>
+ <div
+ v-if="quickActionsDocsPath"
+ class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary"
+ >
+ <div class="gl-w-full gl-line-height-32 gl-font-sm">
+ <gl-sprintf :message="$options.i18n.quickActionsText">
+ <template #keyboard="{ content }">
+ <kbd>{{ content }}</kbd>
+ </template>
+ <template #quickActionsDocsLink="{ content }">
+ <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index fac259cf6a1..c53007b68cf 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -16,6 +16,13 @@ export default {
ToolbarMoreDropdown,
EditorModeSwitcher,
},
+ props: {
+ hideAttachmentButton: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
methods: {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
@@ -114,6 +121,7 @@ export default {
/>
<toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
<toolbar-attachment-button
+ v-if="!hideAttachmentButton"
data-testid="attachment"
@execute="trackToolbarControlExecution"
/>
@@ -125,8 +133,3 @@ export default {
</div>
</div>
</template>
-<style>
-.gl-spinner-container {
- text-align: left;
-}
-</style>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index bf2740f9864..eb7985f628a 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownForm,
- GlButton,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
@@ -19,9 +13,7 @@ const MAX_COLS = 10;
export default {
components: {
GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownForm,
+ GlDisclosureDropdown,
},
directives: {
GlTooltip,
@@ -61,45 +53,72 @@ export default {
.run();
this.resetState();
+ this.$refs.dropdown.close();
this.$emit('execute', { contentType: 'table' });
},
getButtonLabel(rows, cols) {
- return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols });
+ return sprintf(__('Insert a %{rows}×%{cols} table'), { rows, cols });
+ },
+ onKeydown(key) {
+ const delta = {
+ ArrowUp: { rows: -1, cols: 0 },
+ ArrowDown: { rows: 1, cols: 0 },
+ ArrowLeft: { rows: 0, cols: -1 },
+ ArrowRight: { rows: 0, cols: 1 },
+ }[key] || { rows: 0, cols: 0 };
+
+ const rows = clamp(this.rows + delta.rows, 1, this.maxRows);
+ const cols = clamp(this.cols + delta.cols, 1, this.maxCols);
+
+ this.setRowsAndCols(rows, cols);
+ },
+ setFocus(row, col) {
+ this.$refs[`table-${row}-${col}`][0].$el.focus();
},
},
+ MAX_COLS,
+ MAX_ROWS,
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
+ ref="dropdown"
v-gl-tooltip
size="small"
category="tertiary"
icon="table"
- :title="__('Insert table')"
- :text="__('Insert table')"
- class="content-editor-dropdown"
- right
+ :aria-label="__('Insert table')"
+ :toggle-text="__('Insert table')"
+ positioning-strategy="fixed"
+ class="content-editor-table-dropdown"
text-sr-only
- lazy
+ :fluid-width="true"
+ @shown="setFocus(1, 1)"
>
- <gl-dropdown-form class="gl-px-3! gl-pb-2!">
- <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="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }"
- :aria-label="getButtonLabel(r, c)"
- class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!"
- @mouseover="setRowsAndCols(r, c)"
- @click="insertTable()"
- />
- </div>
- <gl-dropdown-divider class="gl-my-3! gl-mx-n3!" />
- <div class="gl-px-1">
- {{ getButtonLabel(rows, cols) }}
+ <div
+ class="gl-p-3 gl-pt-2"
+ role="grid"
+ :aria-colcount="$options.MAX_COLS"
+ :aria-rowcount="$options.MAX_ROWS"
+ >
+ <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row">
+ <div v-for="c of list(maxCols)" :key="c" role="gridcell">
+ <gl-button
+ :ref="`table-${r}-${c}`"
+ :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }"
+ :aria-label="getButtonLabel(r, c)"
+ class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!"
+ @mouseover="setRowsAndCols(r, c)"
+ @focus="setRowsAndCols(r, c)"
+ @click="insertTable()"
+ @keydown="onKeydown($event.key)"
+ />
+ </div>
</div>
- </gl-dropdown-form>
- </gl-dropdown>
+ </div>
+ <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2">
+ {{ getButtonLabel(rows, cols) }}
+ </div>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
index 4126c65d87f..2b4b9891c77 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
@@ -13,6 +13,11 @@ export default {
type: Object,
required: true,
},
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
text() {
@@ -31,13 +36,18 @@ export default {
};
</script>
<template>
- <node-view-wrapper class="gl-display-inline-block">
+ <node-view-wrapper as="span">
<span v-if="isCommand">{{ text }}</span>
<gl-link
v-else
href="#"
- class="gfm"
- :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }"
+ tabindex="-1"
+ class="gfm gl-cursor-text"
+ :class="{
+ 'gfm-project_member': isMember,
+ 'current-user': isMember && isCurrentUser,
+ 'ProseMirror-selectednode': selected,
+ }"
@click.prevent.stop
>{{ text }}</gl-link
>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
index 4206c866032..c67e699cf95 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
@@ -14,21 +14,28 @@ export default {
type: Object,
required: true,
},
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isScopedLabel() {
- return isScopedLabel({ title: this.node.attrs.originalText });
+ return isScopedLabel({ title: this.node.attrs.originalText || this.node.attrs.text });
},
},
+ fallbackLabelBackgroundColor: '#ccc',
};
</script>
<template>
- <node-view-wrapper class="gl-display-inline-block">
+ <node-view-wrapper as="span" :class="{ 'ProseMirror-selectednode': selected }">
<gl-label
size="sm"
:scoped="isScopedLabel"
- :background-color="node.attrs.color"
+ :background-color="node.attrs.color || $options.fallbackLabelBackgroundColor"
:title="node.attrs.text"
+ class="gl-pointer-events-none"
/>
</node-view-wrapper>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index 5624bae34c2..44f5a2895fd 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -1,22 +1,71 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
-import { __ } from '~/locale';
+import { __, n__ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
const TABLE_CELL_BODY = 'td';
+function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) {
+ const totalRows = selectedRect?.map.height;
+ const totalCols = selectedRect?.map.width;
+ const isTableBodyCell = cellType === TABLE_CELL_BODY;
+ const selectedRows = selectedRect ? selectedRect.bottom - selectedRect.top : 0;
+ const selectedCols = selectedRect ? selectedRect.right - selectedRect.left : 0;
+ const showSplitCellOption =
+ selectedRows === rowspan && selectedCols === colspan && (rowspan > 1 || colspan > 1);
+ const showMergeCellsOption = selectedRows !== rowspan || selectedCols !== colspan;
+ const numCellsToMerge = (selectedRows - rowspan + 1) * (selectedCols - colspan + 1);
+ const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell;
+ const showDeleteColumnOption = totalCols > selectedCols;
+
+ return [
+ {
+ items: [
+ { text: __('Insert column before'), value: 'addColumnBefore' },
+ { text: __('Insert column after'), value: 'addColumnAfter' },
+ isTableBodyCell && { text: __('Insert row before'), value: 'addRowBefore' },
+ { text: __('Insert row after'), value: 'addRowAfter' },
+ ].filter(Boolean),
+ },
+ {
+ items: [
+ showSplitCellOption && { text: __('Split cell'), value: 'splitCell' },
+ showMergeCellsOption && {
+ text: n__('Merge %d cell', 'Merge %d cells', numCellsToMerge),
+ value: 'mergeCells',
+ },
+ ].filter(Boolean),
+ },
+ {
+ items: [
+ showDeleteRowOption && {
+ text: n__('Delete row', 'Delete %d rows', selectedRows),
+ value: 'deleteRow',
+ },
+ showDeleteColumnOption && {
+ text: n__('Delete column', 'Delete %d columns', selectedCols),
+ value: 'deleteColumn',
+ },
+ { text: __('Delete table'), value: 'deleteTable' },
+ ].filter(Boolean),
+ },
+ ].filter(({ items }) => items.length);
+}
+
export default {
name: 'TableCellBaseWrapper',
components: {
NodeViewWrapper,
NodeViewContent,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlDisclosureDropdown,
},
props: {
+ getPos: {
+ type: Function,
+ required: true,
+ },
cellType: {
type: String,
validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type),
@@ -34,19 +83,17 @@ export default {
data() {
return {
displayActionsDropdown: false,
- preventHide: true,
selectedRect: null,
};
},
computed: {
- totalRows() {
- return this.selectedRect?.map.height;
- },
- totalCols() {
- return this.selectedRect?.map.width;
- },
- isTableBodyCell() {
- return this.cellType === TABLE_CELL_BODY;
+ dropdownItems() {
+ return getDropdownItems({
+ selectedRect: this.selectedRect,
+ cellType: this.cellType,
+ rowspan: this.node.attrs.rowspan,
+ colspan: this.node.attrs.colspan,
+ });
},
},
mounted() {
@@ -61,6 +108,13 @@ export default {
const { state } = this.editor;
const { $cursor } = state.selection;
+ try {
+ this.selectedRect = getSelectedRect(state);
+ } catch (e) {
+ // ignore error if the selection is not in a table
+ return;
+ }
+
if (!$cursor) return;
this.displayActionsDropdown = false;
@@ -71,54 +125,34 @@ export default {
break;
}
}
-
- if (this.displayActionsDropdown) {
- this.selectedRect = getSelectedRect(state);
- }
},
- runCommand(command) {
- this.editor.chain()[command]().run();
+
+ runCommand({ value: command }) {
this.hideDropdown();
+ this.editor.chain()[command]().run();
},
- handleHide($event) {
- if (this.preventHide) {
- $event.preventDefault();
- }
- this.preventHide = true;
- },
+
hideDropdown() {
- this.preventHide = false;
- this.$refs.dropdown?.hide();
+ this.$refs.dropdown?.close();
},
},
- i18n: {
- insertColumnBefore: __('Insert column before'),
- insertColumnAfter: __('Insert column after'),
- insertRowBefore: __('Insert row before'),
- insertRowAfter: __('Insert row after'),
- deleteRow: __('Delete row'),
- deleteColumn: __('Delete column'),
- deleteTable: __('Delete table'),
- editTableActions: __('Edit table'),
- },
- dropdownPopperOpts: {
- positionFixed: true,
- },
};
</script>
<template>
<node-view-wrapper
- class="gl-relative gl-padding-5 gl-min-w-10"
:as="cellType"
+ :rowspan="node.attrs.rowspan || 1"
+ :colspan="node.attrs.colspan || 1"
dir="auto"
+ class="gl-m-0! gl-p-0! gl-relative"
@click="hideDropdown"
>
<span
v-if="displayActionsDropdown"
contenteditable="false"
- class="gl-absolute gl-right-0 gl-top-0"
+ class="gl-absolute gl-right-0 gl-top-0 gl-pr-1 gl-pt-1"
>
- <gl-dropdown
+ <gl-disclosure-dropdown
ref="dropdown"
dropup
icon="chevron-down"
@@ -127,34 +161,12 @@ export default {
boundary="viewport"
no-caret
text-sr-only
- :text="$options.i18n.editTableActions"
- :popper-opts="$options.dropdownPopperOpts"
- @hide="handleHide($event)"
- >
- <gl-dropdown-item @click="runCommand('addColumnBefore')">
- {{ $options.i18n.insertColumnBefore }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="runCommand('addColumnAfter')">
- {{ $options.i18n.insertColumnAfter }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')">
- {{ $options.i18n.insertRowBefore }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="runCommand('addRowAfter')">
- {{ $options.i18n.insertRowAfter }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')">
- {{ $options.i18n.deleteRow }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')">
- {{ $options.i18n.deleteColumn }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="runCommand('deleteTable')">
- {{ $options.i18n.deleteTable }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="dropdownItems"
+ :toggle-text="__('Edit table')"
+ positioning-strategy="fixed"
+ @action="runCommand"
+ />
</span>
- <node-view-content />
+ <node-view-content as="div" class="gl-p-5 gl-min-w-10" />
</node-view-wrapper>
</template>
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 9e1a4bfe361..1aa6568848f 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -1,26 +1,33 @@
+import { withGitLabAPIAccess } from 'storybook_addons/gitlab_api_access';
+import Api from '~/api';
import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'content_editor/content_editor',
+ title: 'ce/content_editor/content_editor',
+ decorators: [withGitLabAPIAccess],
};
const Template = (_, { argTypes }) => ({
components: { ContentEditor },
props: Object.keys(argTypes),
- template: '<content-editor v-bind="$props" @initialized="loadContent" />',
- methods: {
- loadContent(contentEditor) {
- contentEditor.setSerializedContent('Hello content editor');
- },
- },
+ template: `
+ <content-editor v-bind="$props" />
+ `,
});
export const Default = Template.bind({});
Default.args = {
- renderMarkdown: () => '<p>Hello content editor</p>',
+ project: 'gitlab-org/gitlab-shell',
+ renderMarkdown: async (text) => {
+ const response = await Api.markdown({ text, gfm: true, project: Default.args.project });
+
+ return response.data.html;
+ },
+ markdown: 'This is **bold text**',
uploadsPath: '/uploads/',
serializerConfig: {},
extensions: [],
+ enableAutocomplete: false,
};
diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js
index 53f6d9b995c..8477c8dbd28 100644
--- a/app/assets/javascripts/content_editor/extensions/code.js
+++ b/app/assets/javascripts/content_editor/extensions/code.js
@@ -1,12 +1,22 @@
+import { Mark } from '@tiptap/core';
import Code from '@tiptap/extension-code';
import { EXTENSION_PRIORITY_LOWER } from '../constants';
export default Code.extend({
excludes: null,
+
/**
* Reduce the rendering priority of the code mark to
* ensure the bold, italic, and strikethrough marks
* are rendered first.
*/
priority: EXTENSION_PRIORITY_LOWER,
+
+ addKeyboardShortcuts() {
+ return {
+ ArrowRight: () => {
+ return Mark.handleExit({ editor: this.editor, mark: this });
+ },
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js
index 06fecf8196d..d3fa4bb84bd 100644
--- a/app/assets/javascripts/content_editor/extensions/description_item.js
+++ b/app/assets/javascripts/content_editor/extensions/description_item.js
@@ -39,9 +39,13 @@ export default Node.create({
addKeyboardShortcuts() {
return {
Enter: () => {
+ if (!this.editor.isActive('descriptionItem')) return false;
+
return this.editor.commands.splitListItem('descriptionItem');
},
Tab: () => {
+ if (!this.editor.isActive('descriptionItem')) return false;
+
const { isTerm } = this.editor.getAttributes('descriptionItem');
if (isTerm)
return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm });
@@ -49,6 +53,8 @@ export default Node.create({
return false;
},
'Shift-Tab': () => {
+ if (!this.editor.isActive('descriptionItem')) return false;
+
const { isTerm } = this.editor.getAttributes('descriptionItem');
if (isTerm) return this.editor.commands.liftListItem('descriptionItem');
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
index fbe58664a10..61bef0729db 100644
--- a/app/assets/javascripts/content_editor/extensions/details_content.js
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -26,8 +26,16 @@ export default Node.create({
addKeyboardShortcuts() {
return {
- Enter: () => this.editor.commands.splitListItem('detailsContent'),
- 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'),
+ Enter: () => {
+ if (!this.editor.isActive('detailsContent')) return false;
+
+ return this.editor.commands.splitListItem('detailsContent');
+ },
+ 'Shift-Tab': () => {
+ if (!this.editor.isActive('detailsContent')) return false;
+
+ return this.editor.commands.liftListItem('detailsContent');
+ },
};
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
index 8c3012ecf59..0d453919571 100644
--- a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
+++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
@@ -1,7 +1,6 @@
import { create } from '~/drawio/content_editor_facade';
import { launchDrawioEditor } from '~/drawio/drawio_editor';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
-import createAssetResolver from '../services/asset_resolver';
import Image from './image';
export default Image.extend({
@@ -10,7 +9,7 @@ export default Image.extend({
return {
...this.parent?.(),
uploadsPath: null,
- renderMarkdown: null,
+ assetResolver: null,
};
},
parseHTML() {
@@ -32,7 +31,7 @@ export default Image.extend({
tiptapEditor: this.editor,
drawioNodeName: this.name,
uploadsPath: this.options.uploadsPath,
- assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }),
+ assetResolver: this.options.assetResolver,
}),
});
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index b83814103d1..584e7b9e4f7 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -40,7 +40,6 @@ export default Link.extend({
},
addAttributes() {
return {
- ...this.parent?.(),
uploading: {
default: false,
renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}),
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 82fa5ce6c1d..db13438de5e 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -1,5 +1,7 @@
+import OrderedMap from 'orderedmap';
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/alert';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
@@ -9,47 +11,55 @@ import Diagram from './diagram';
import Frontmatter from './frontmatter';
const TEXT_FORMAT = 'text/plain';
+const GFM_FORMAT = 'text/x-gfm';
const HTML_FORMAT = 'text/html';
const VS_CODE_FORMAT = 'vscode-editor-data';
const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
+function parseHTML(schema, html) {
+ const parser = new DOMParser();
+ const startTag = '<body>';
+ const endTag = '</body>';
+ const { body } = parser.parseFromString(startTag + html + endTag, 'text/html');
+ return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
+}
+
export default Extension.create({
name: 'pasteMarkdown',
priority: EXTENSION_PRIORITY_HIGHEST,
addOptions() {
return {
renderMarkdown: null,
+ serializer: null,
};
},
addCommands() {
return {
- pasteMarkdown: (markdown) => () => {
+ pasteContent: (content = '', processMarkdown = true) => async () => {
const { editor, options } = this;
const { renderMarkdown, eventHub } = options;
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- deserializer
- .deserialize({ schema: editor.schema, markdown })
+ const pasteSchemaSpec = { ...editor.schema.spec };
+ pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span');
+ pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre');
+ const schema = new Schema(pasteSchemaSpec);
+
+ const promise = processMarkdown
+ ? deserializer.deserialize({ schema, markdown: content })
+ : Promise.resolve(parseHTML(schema, content));
+
+ promise
.then(({ document }) => {
- if (!document) {
- return;
- }
+ if (!document) return;
- const { state, view } = editor;
- const { tr, selection } = state;
const { firstChild } = document.content;
- const content =
+ const toPaste =
document.content.childCount === 1 && firstChild.type.name === 'paragraph'
? firstChild.content
: document.content;
- if (selection.to - selection.from > 0) {
- tr.replaceWith(selection.from, selection.to, content);
- } else {
- tr.insert(selection.from, content);
- }
-
- view.dispatch(tr);
+ editor.commands.insertContent(toPaste.toJSON());
})
.catch(() => {
eventHub.$emit(ALERT_EVENT, {
@@ -65,24 +75,57 @@ export default Extension.create({
addProseMirrorPlugins() {
let pasteRaw = false;
+ const handleCutAndCopy = (view, event) => {
+ const slice = view.state.selection.content();
+ const gfmContent = this.options.serializer.serialize({ doc: slice.content });
+ const documentFragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(
+ slice.content,
+ );
+ const div = document.createElement('div');
+ div.appendChild(documentFragment);
+
+ event.clipboardData.setData(TEXT_FORMAT, div.innerText);
+ event.clipboardData.setData(HTML_FORMAT, div.innerHTML);
+ event.clipboardData.setData(GFM_FORMAT, gfmContent);
+
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
+ handleDOMEvents: {
+ copy: handleCutAndCopy,
+ cut: (view, event) => {
+ handleCutAndCopy(view, event);
+ this.editor.commands.deleteSelection();
+ },
+ },
handleKeyDown: (_, event) => {
pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey;
},
handlePaste: (view, event) => {
const { clipboardData } = event;
- const content = clipboardData.getData(TEXT_FORMAT);
- const { state } = view;
- const { tr, selection } = state;
- const { from, to } = selection;
+
+ const gfmContent = clipboardData.getData(GFM_FORMAT);
+
+ if (gfmContent) {
+ return this.editor.commands.pasteContent(gfmContent, true);
+ }
+
+ const textContent = clipboardData.getData(TEXT_FORMAT);
+ const htmlContent = clipboardData.getData(HTML_FORMAT);
+
+ const { from, to } = view.state.selection;
if (pasteRaw) {
- tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to);
- view.dispatch(tr);
+ this.editor.commands.insertContentAt(
+ { from, to },
+ textContent.replace(/^\s+|\s+$/gm, ''),
+ );
return true;
}
@@ -91,18 +134,19 @@ export default Extension.create({
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
const language = vsCodeMeta.mode;
- if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
- return false;
- }
-
// if a code block is active, paste as plain text
- if (CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) {
+ if (!textContent || CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) {
return false;
}
- this.editor.commands.pasteMarkdown(content);
+ if (hasVsCode) {
+ return this.editor.commands.pasteContent(
+ language === 'markdown' ? textContent : `\`\`\`${language}\n${textContent}\n\`\`\``,
+ true,
+ );
+ }
- return true;
+ return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML);
},
},
}),
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index b56aa8596a0..ef69b9bbda6 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, InputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ReferenceWrapper from '../components/wrappers/reference.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
@@ -8,6 +8,21 @@ const getAnchor = (element) => {
return element.querySelector('a');
};
+const findReference = (editor, reference) => {
+ let position;
+
+ editor.view.state.doc.descendants((descendant, pos) => {
+ if (descendant.isText && descendant.text.includes(reference)) {
+ position = pos + descendant.text.indexOf(reference);
+ return false;
+ }
+
+ return true;
+ });
+
+ return position;
+};
+
export default Node.create({
name: 'reference',
@@ -17,6 +32,12 @@ export default Node.create({
atom: true,
+ addOptions() {
+ return {
+ assetResolver: null,
+ };
+ },
+
addAttributes() {
return {
className: {
@@ -42,6 +63,54 @@ export default Node.create({
};
},
+ addInputRules() {
+ const { editor } = this;
+ const { assetResolver } = this.options;
+ const referenceInputRegex = /(?:^|\s)([\w/]*([!&#])\d+(\+?s?))(?:\s|\n)/m;
+ const referenceTypes = {
+ '#': 'issue',
+ '!': 'merge_request',
+ '&': 'epic',
+ };
+
+ return [
+ new InputRule({
+ find: referenceInputRegex,
+ handler: async ({ match }) => {
+ const [, referenceId, referenceSymbol, expansionType] = match;
+ const referenceType = referenceTypes[referenceSymbol];
+
+ const {
+ href,
+ text,
+ expandedText,
+ fullyExpandedText,
+ } = await assetResolver.resolveReference(referenceId);
+
+ if (!text) return;
+
+ let referenceText = text;
+ if (expansionType === '+') referenceText = expandedText;
+ if (expansionType === '+s') referenceText = fullyExpandedText;
+
+ const position = findReference(editor, referenceId);
+ if (!position) return;
+
+ editor.view.dispatch(
+ editor.state.tr.replaceWith(position, position + referenceId.length, [
+ this.type.create({
+ referenceType,
+ originalText: referenceId,
+ href,
+ text: referenceText,
+ }),
+ ]),
+ );
+ },
+ }),
+ ];
+ },
+
parseHTML() {
return [
{
@@ -51,6 +120,19 @@ export default Node.create({
];
},
+ renderHTML({ node }) {
+ return [
+ 'gl-reference',
+ {
+ 'data-reference-type': node.attrs.referenceType,
+ 'data-original-text': node.attrs.originalText,
+ href: node.attrs.href,
+ text: node.attrs.text,
+ },
+ node.attrs.text,
+ ];
+ },
+
addNodeView() {
return new VueNodeViewRenderer(ReferenceWrapper);
},
diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js
index 0441f8ef8d2..9cd55a0f87c 100644
--- a/app/assets/javascripts/content_editor/extensions/reference_label.js
+++ b/app/assets/javascripts/content_editor/extensions/reference_label.js
@@ -4,7 +4,7 @@ import LabelWrapper from '../components/wrappers/reference_label.vue';
import Reference from './reference';
export default Reference.extend({
- name: 'reference_label',
+ name: 'referenceLabel',
addAttributes() {
return {
@@ -20,11 +20,21 @@ export default Reference.extend({
},
color: {
default: null,
- parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor,
+ parseHTML: (element) => {
+ let color = element.querySelector('.gl-label-text').style.backgroundColor;
+ if (!color || color.startsWith('var'))
+ color = element.style.getPropertyValue('--label-background-color');
+
+ return color;
+ },
},
};
},
+ addInputRules() {
+ return [];
+ },
+
parseHTML() {
return [{ tag: 'span.gl-label' }];
},
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index e72b5c7365c..f29222a5289 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -162,7 +162,7 @@ export default Node.create({
editor: this.editor,
char: '~',
dataSource: this.options.autocompleteDataSources.labels,
- nodeType: 'reference_label',
+ nodeType: 'referenceLabel',
nodeProps: {
referenceType: 'label',
},
diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js
index c0bcddbe58d..0d4396fc176 100644
--- a/app/assets/javascripts/content_editor/services/asset_resolver.js
+++ b/app/assets/javascripts/content_editor/services/asset_resolver.js
@@ -2,23 +2,46 @@ import { memoize } from 'lodash';
const parser = new DOMParser();
-export default ({ renderMarkdown }) => ({
- resolveUrl: memoize(async (canonicalSrc) => {
- const html = await renderMarkdown(`[link](${canonicalSrc})`);
+export default class AssetResolver {
+ constructor({ renderMarkdown }) {
+ this.renderMarkdown = renderMarkdown;
+ }
+
+ resolveUrl = memoize(async (canonicalSrc) => {
+ const html = await this.renderMarkdown(`[link](${canonicalSrc})`);
if (!html) return canonicalSrc;
const { body } = parser.parseFromString(html, 'text/html');
return body.querySelector('a').getAttribute('href');
- }),
+ });
+
+ resolveReference = memoize(async (originalText) => {
+ const text = originalText.replace(/(\+|\+s)$/, '');
+ const toRender = `${text} ${text}+ ${text}+s`;
+ const html = await this.renderMarkdown(toRender);
+
+ if (!html) return {};
+
+ const { body } = parser.parseFromString(html, 'text/html');
+ const a = body.querySelectorAll('a');
+ if (!a.length) return {};
+
+ return {
+ href: a[0].getAttribute('href'),
+ text: a[0].textContent,
+ expandedText: a[1].textContent,
+ fullyExpandedText: a[2].textContent,
+ };
+ });
- renderDiagram: memoize(async (code, language) => {
+ renderDiagram = memoize(async (code, language) => {
const backticks = '`'.repeat(4);
- const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`);
+ const html = await this.renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`);
const { body } = parser.parseFromString(html, 'text/html');
const img = body.querySelector('img');
if (!img) return '';
return img.dataset.src || img.getAttribute('src');
- }),
-});
+ });
+}
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index a988e1df2a6..ec0f2f028d9 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -56,6 +56,10 @@ export class ContentEditor {
return this._assetResolver.resolveUrl(canonicalSrc);
}
+ resolveReference(originalText) {
+ return this._assetResolver.resolveReference(originalText);
+ }
+
renderDiagram(code, language) {
return this._assetResolver.renderDiagram(code, language);
}
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 3958f77745a..ee1f706ec7e 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -64,10 +64,10 @@ import Text from '../extensions/text';
import Video from '../extensions/video';
import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor';
-import createMarkdownSerializer from './markdown_serializer';
+import MarkdownSerializer from './markdown_serializer';
import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer';
import createRemarkMarkdownDeserializer from './remark_markdown_deserializer';
-import createAssetResolver from './asset_resolver';
+import AssetResolver from './asset_resolver';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
@@ -96,6 +96,13 @@ export const createContentEditor = ({
}
const eventHub = eventHubFactory();
+ const assetResolver = new AssetResolver({ renderMarkdown });
+ const serializer = new MarkdownSerializer({ serializerConfig });
+ const deserializer = window.gon?.features?.preserveUnchangedMarkdown
+ ? createRemarkMarkdownDeserializer()
+ : createGlApiMarkdownDeserializer({
+ render: renderMarkdown,
+ });
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
@@ -138,8 +145,8 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown.configure({ eventHub, renderMarkdown }),
- Reference,
+ PasteMarkdown.configure({ eventHub, renderMarkdown, serializer }),
+ Reference.configure({ assetResolver }),
ReferenceLabel,
ReferenceDefinition,
Selection,
@@ -162,17 +169,10 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources }));
- if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown }));
+ if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, assetResolver }));
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
- const serializer = createMarkdownSerializer({ serializerConfig });
- const deserializer = window.gon?.features?.preserveUnchangedMarkdown
- ? createRemarkMarkdownDeserializer()
- : createGlApiMarkdownDeserializer({
- render: renderMarkdown,
- });
- const assetResolver = createAssetResolver({ renderMarkdown });
return new ContentEditor({
tiptapEditor,
diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
index a0ebbebed4e..5e7c981ace3 100644
--- a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
+++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js
@@ -47,6 +47,10 @@ export default {
'clojure-repl': () =>
import(/* webpackChunkName: 'hl-clojure-repl' */ 'highlight.js/lib/languages/clojure-repl'),
clojure: () => import(/* webpackChunkName: 'hl-clojure' */ 'highlight.js/lib/languages/clojure'),
+ codeowners: () =>
+ import(
+ /* webpackChunkName: 'hl-codeowners' */ '~/vue_shared/components/source_viewer/languages/codeowners'
+ ),
cmake: () => import(/* webpackChunkName: 'hl-cmake' */ 'highlight.js/lib/languages/cmake'),
coffeescript: () =>
import(/* webpackChunkName: 'hl-coffeescript' */ 'highlight.js/lib/languages/coffeescript'),
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 3b77064e903..4dbafd1632d 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -67,6 +67,7 @@ import {
renderContent,
renderBulletList,
renderReference,
+ renderReferenceLabel,
preserveUnchanged,
bold,
italic,
@@ -197,7 +198,7 @@ const defaultSerializerConfig = {
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: renderReference,
- [ReferenceLabel.name]: renderReference,
+ [ReferenceLabel.name]: renderReferenceLabel,
[ReferenceDefinition.name]: preserveUnchanged({
render: (state, node, parent, index, same, sourceMarkdown) => {
const nextSibling = parent.maybeChild(index + 1);
@@ -273,19 +274,22 @@ const createChangeTracker = (doc, pristineDoc) => {
return changeTracker;
};
-/**
- * Converts a ProseMirror document to Markdown. See the
- * following documentation to learn how to implement
- * custom node and mark serializer functions.
- *
- * https://github.com/prosemirror/prosemirror-markdown
- *
- * @param {Object} params.nodes ProseMirror node serializer functions
- * @param {Object} params.marks ProseMirror marks serializer config
- *
- * @returns a markdown serializer
- */
-export default ({ serializerConfig = {} } = {}) => ({
+export default class MarkdownSerializer {
+ /**
+ * Converts a ProseMirror document to Markdown. See the
+ * following documentation to learn how to implement
+ * custom node and mark serializer functions.
+ *
+ * https://github.com/prosemirror/prosemirror-markdown
+ *
+ * @param {Object} params.nodes ProseMirror node serializer functions
+ * @param {Object} params.marks ProseMirror marks serializer config
+ *
+ * @returns a markdown serializer
+ */
+ constructor({ serializerConfig = {} } = {}) {
+ this.serializerConfig = serializerConfig;
+ }
/**
* Serializes a ProseMirror document as Markdown. If a node contains
* sourcemap metadata, the serializer is capable of restoring the
@@ -301,22 +305,23 @@ export default ({ serializerConfig = {} } = {}) => ({
* changed.
* @returns A String that represents the serialized document as Markdown
*/
- serialize: ({ doc, pristineDoc }) => {
+ serialize({ doc, pristineDoc }) {
const changeTracker = createChangeTracker(doc, pristineDoc);
const serializer = new ProseMirrorMarkdownSerializer(
{
...defaultSerializerConfig.nodes,
- ...serializerConfig.nodes,
+ ...this.serializerConfig.nodes,
},
{
...defaultSerializerConfig.marks,
- ...serializerConfig.marks,
+ ...this.serializerConfig.marks,
},
);
return serializer.serialize(doc, {
tightLists: true,
changeTracker,
+ escapeExtraCharacters: /<|>/g,
});
- },
-});
+ }
+}
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 478b87372d7..b2cbc9c3fed 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -53,6 +53,16 @@ function getRowsAndCells(table) {
return { rows, cells };
}
+// Buffers the output of the given action (fn) and returns the output that was written
+// to the prosemirror-markdown serializer state output.
+function buffer(state, action = () => {}) {
+ const buf = state.out;
+ action();
+ const retval = state.out.substring(buf.length);
+ state.out = buf;
+ return retval;
+}
+
function getChildren(node) {
const children = [];
for (let i = 0; i < node.childCount; i += 1) {
@@ -147,6 +157,11 @@ function setIsInBlockTable(table, value) {
});
}
+function ensureSpace(state) {
+ state.flushClose();
+ if (!state.atBlank() && !state.out.endsWith(' ')) state.write(' ');
+}
+
function unsetIsInBlockTable(table) {
tableMap.delete(table);
@@ -194,7 +209,8 @@ function renderTableRowAsMarkdown(state, node, isHeaderRow = false) {
if (i) state.write(' | ');
const { length } = state.out;
- state.render(cell, node, i);
+ const cellContent = buffer(state, () => state.render(cell, node, i));
+ state.write(cellContent.replace(/\|/g, '\\|'));
cellWidths.push(state.out.length - length);
});
state.write(' |');
@@ -212,13 +228,20 @@ function renderTableRowAsHTML(state, node) {
renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown'));
- if (!containsParagraphWithOnlyText(cell)) {
- state.closeBlock(node);
- state.flushClose();
- }
+ const buffered = buffer(state, () => {
+ if (!containsParagraphWithOnlyText(cell)) {
+ state.closeBlock(node);
+ state.flushClose();
+ }
- state.render(cell, node, i);
- state.flushClose(1);
+ state.render(cell, node, i);
+ state.flushClose(1);
+ });
+ if (buffered.includes('\\') && !buffered.includes('\n')) {
+ state.out += `\n\n${buffered}\n`;
+ } else {
+ state.out += buffered;
+ }
renderTagClose(state, tag);
});
@@ -253,7 +276,14 @@ export function renderContent(state, node, forceRenderInline) {
export function renderHTMLNode(tagName, forceRenderContentInline = false) {
return (state, node) => {
renderTagOpen(state, tagName, node.attrs);
- renderContent(state, node, forceRenderContentInline);
+
+ const buffered = buffer(state, () => renderContent(state, node, forceRenderContentInline));
+ if (buffered.includes('\\') && !buffered.includes('\n')) {
+ state.out += `\n\n${buffered}\n`;
+ } else {
+ state.out += buffered;
+ }
+
renderTagClose(state, tagName, false);
if (forceRenderContentInline) {
@@ -446,9 +476,15 @@ export function renderOrderedList(state, node) {
}
export function renderReference(state, node) {
+ ensureSpace(state);
state.write(node.attrs.originalText || node.attrs.text);
}
+export function renderReferenceLabel(state, node) {
+ ensureSpace(state);
+ state.write(node.attrs.originalText || `~${state.quote(node.attrs.text)}`);
+}
+
const generateBoldTags = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1];
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index ea444b5c146..ab5f01227fb 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -108,27 +108,5 @@ export default class ContextualSidebar {
const collapse = parseBoolean(getCookie('sidebar_collapsed'));
this.toggleCollapsedSidebar(collapse, true);
}
-
- const modalEl = document.querySelector('.js-invite-members-modal');
- if (modalEl) {
- import(
- /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
- )
- .then(({ default: initInviteMembersModal }) => {
- initInviteMembersModal();
- })
- .catch(() => {});
-
- const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger');
- if (inviteTriggers) {
- import(
- /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger'
- )
- .then(({ default: initInviteMembersTrigger }) => {
- initInviteMembersTrigger();
- })
- .catch(() => {});
- }
- }
}
}
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue
new file mode 100644
index 00000000000..a7787ae84bc
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_approved.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TargetLink from '../target_link.vue';
+import ResourceParentLink from '../resource_parent_link.vue';
+import ContributionEventBase from './contribution_event_base.vue';
+
+export default {
+ name: 'ContributionEventApproved',
+ i18n: {
+ message: s__(
+ 'ContributionEvent|Approved merge request %{targetLink} in %{resourceParentLink}.',
+ ),
+ },
+ components: { ContributionEventBase, GlSprintf, TargetLink, ResourceParentLink },
+ props: {
+ /**
+ * Expected format
+ * {
+ * created_at: string;
+ * action: "approved"
+ * author: {
+ * id: number;
+ * username: string;
+ * name: string;
+ * state: string;
+ * avatar_url: string;
+ * web_url: string;
+ * };
+ * target: {
+ * id: number;
+ * type: "MergeRequest"
+ * title: string;
+ * reference_link_text: string;
+ * web_url: string;
+ * };
+ * resource_parent: {
+ * type: "project";
+ * full_name: string;
+ * full_path: string;
+ * web_url: string;
+ * avatar_url: string;
+ * };
+ * };
+ */
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <contribution-event-base :event="event" icon-name="approval-solid" icon-class="gl-text-green-500">
+ <gl-sprintf :message="$options.i18n.message">
+ <template #targetLink>
+ <target-link :event="event" />
+ </template>
+ <template #resourceParentLink>
+ <resource-parent-link :event="event" />
+ </template>
+ </gl-sprintf>
+ </contribution-event-base>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue
new file mode 100644
index 00000000000..93ac94a6f4f
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: { GlAvatarLabeled, GlAvatarLink, GlIcon, TimeAgoTooltip },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ iconClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ author() {
+ return this.event.author;
+ },
+ authorUsername() {
+ return `@${this.author.username}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-mt-5 gl-pb-5 gl-border-b gl-relative">
+ <time-ago-tooltip :time="event.created_at" class="gl-float-right gl-text-secondary" />
+ <gl-avatar-link :href="author.web_url">
+ <gl-avatar-labeled
+ :label="author.name"
+ :sub-label="authorUsername"
+ :src="author.avatar_url"
+ :size="32"
+ />
+ </gl-avatar-link>
+ <div class="gl-pl-8 gl-mt-2" data-testid="event-body">
+ <div class="gl-text-secondary">
+ <gl-icon :class="iconClass" :name="iconName" />
+ <slot></slot>
+ </div>
+ <div v-if="$scopedSlots['additional-info']" class="gl-mt-2">
+ <slot name="additional-info"></slot>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/contribution_events.vue b/app/assets/javascripts/contribution_events/components/contribution_events.vue
new file mode 100644
index 00000000000..41ec4f5692e
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/contribution_events.vue
@@ -0,0 +1,119 @@
+<script>
+import EmptyComponent from '~/vue_shared/components/empty_component';
+import { EVENT_TYPE_APPROVED } from '../constants';
+import ContributionEventApproved from './contribution_event/contribution_event_approved.vue';
+
+export default {
+ props: {
+ /**
+ * Expected format
+ * {
+ * created_at: string;
+ * action:
+ * | "created"
+ * | "updated"
+ * | "closed"
+ * | "reopened"
+ * | "pushed"
+ * | "commented"
+ * | "merged"
+ * | "joined"
+ * | "left"
+ * | "destroyed"
+ * | "expired"
+ * | "approved"
+ * | "private";
+ * ref?: {
+ * type: "branch" | "tag";
+ * count: number;
+ * name: string;
+ * path: string;
+ * is_new: boolean;
+ * is_removed: boolean;
+ * };
+ * commit?: {
+ * truncated_sha: string;
+ * path: string;
+ * title: string;
+ * count: number;
+ * create_mr_path: string;
+ * from_truncated_sha?: string;
+ * to_truncated_sha?: string;
+ * compare_path?: string;
+ * };
+ * author: {
+ * id: number;
+ * username: string;
+ * name: string;
+ * state: string;
+ * avatar_url: string;
+ * web_url: string;
+ * };
+ * noteable?: {
+ * type: string;
+ * reference_link_text: string;
+ * web_url: string;
+ * first_line_in_markdown: string;
+ * };
+ * target?: {
+ * id: number;
+ * type:
+ * | "Issue"
+ * | "Milestone"
+ * | "MergeRequest"
+ * | "Note"
+ * | "Project"
+ * | "Snippet"
+ * | "User"
+ * | "WikiPage::Meta"
+ * | "DesignManagement::Design";
+ * title: string;
+ * issue_type?:
+ * | "issue"
+ * | "incident"
+ * | "test_case"
+ * | "requirement"
+ * | "task"
+ * | "objective"
+ * | "key_result";
+ * reference_link_text?: string;
+ * web_url: string;
+ * };
+ * resource_parent?: {
+ * type: "project" | "group";
+ * full_name: string;
+ * full_path: string;
+ * web_url: string;
+ * avatar_url: string;
+ * };
+ * }[];
+ */
+ events: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ eventComponent(action) {
+ switch (action) {
+ case EVENT_TYPE_APPROVED:
+ return ContributionEventApproved;
+
+ default:
+ return EmptyComponent;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-list-style-none gl-p-0">
+ <component
+ :is="eventComponent(event.action)"
+ v-for="(event, index) in events"
+ :key="index"
+ :event="event"
+ />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/resource_parent_link.vue b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue
new file mode 100644
index 00000000000..5add9d788bb
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/resource_parent_link.vue
@@ -0,0 +1,22 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: { GlLink },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ resourceParent() {
+ return this.event.resource_parent;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link :href="resourceParent.web_url">{{ resourceParent.full_name }}</gl-link>
+</template>
diff --git a/app/assets/javascripts/contribution_events/components/target_link.vue b/app/assets/javascripts/contribution_events/components/target_link.vue
new file mode 100644
index 00000000000..a661121b2fb
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/components/target_link.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: { GlLink },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ target() {
+ return this.event.target;
+ },
+ targetLinkText() {
+ return this.target.reference_link_text;
+ },
+ targetLinkAttributes() {
+ return {
+ href: this.target.web_url,
+ title: this.target.title,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link v-bind="targetLinkAttributes">{{ targetLinkText }}</gl-link>
+</template>
diff --git a/app/assets/javascripts/contribution_events/constants.js b/app/assets/javascripts/contribution_events/constants.js
new file mode 100644
index 00000000000..05f968e7bc4
--- /dev/null
+++ b/app/assets/javascripts/contribution_events/constants.js
@@ -0,0 +1,14 @@
+// From app/models/event.rb#L16
+export const EVENT_TYPE_CREATED = 'created';
+export const EVENT_TYPE_UPDATED = 'updated';
+export const EVENT_TYPE_CLOSED = 'closed';
+export const EVENT_TYPE_REOPENED = 'reopened';
+export const EVENT_TYPE_PUSHED = 'pushed';
+export const EVENT_TYPE_COMMENTED = 'commented';
+export const EVENT_TYPE_MERGED = 'merged';
+export const EVENT_TYPE_JOINED = 'joined';
+export const EVENT_TYPE_LEFT = 'left';
+export const EVENT_TYPE_DESTROYED = 'destroyed';
+export const EVENT_TYPE_EXPIRED = 'expired';
+export const EVENT_TYPE_APPROVED = 'approved';
+export const EVENT_TYPE_PRIVATE = 'private';
diff --git a/app/assets/javascripts/crm/components/crm_form.vue b/app/assets/javascripts/crm/components/crm_form.vue
index ea6a6892bbd..0b61ffa091e 100644
--- a/app/assets/javascripts/crm/components/crm_form.vue
+++ b/app/assets/javascripts/crm/components/crm_form.vue
@@ -14,6 +14,8 @@ import { MountingPortal } from 'portal-vue';
import { __ } from '~/locale';
import { logError } from '~/lib/logger';
import { getFirstPropertyValue } from '~/lib/utils/common_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { INDEX_ROUTE_NAME } from '../constants';
const MSG_SAVE_CHANGES = __('Save changes');
@@ -241,17 +243,12 @@ export default {
return data[keys[0]];
},
getDrawerHeaderHeight() {
- const wrapperEl = document.querySelector('.content-wrapper');
-
- if (wrapperEl) {
- return `${wrapperEl.offsetTop}px`;
- }
-
- return '';
+ return getContentWrapperHeight();
},
},
MSG_CANCEL,
INDEX_ROUTE_NAME,
+ DRAWER_Z_INDEX,
};
</script>
@@ -261,6 +258,7 @@ export default {
:header-height="getDrawerHeaderHeight()"
class="gl-drawer-responsive"
:open="drawerOpen"
+ :z-index="$options.DRAWER_Z_INDEX"
@close="close(false)"
>
<template #title>
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index a46a8d4affa..08177cd0eac 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -25,6 +25,7 @@ import syntaxHighlight from '~/syntax_highlight';
import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
import * as constants from '~/notes/constants';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { COMMENT_FORM, UPDATE_COMMENT_FORM } from '~/notes/i18n';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
@@ -687,26 +688,36 @@ export default class Notes {
return this.renderNote(note);
}
- addNoteError($form) {
+ addNoteError(error, $form) {
let formParentTimeline;
if ($form.hasClass('js-main-target-form')) {
formParentTimeline = $form.parents('.timeline');
} else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
+
+ const serverErrorMessage = error?.response?.data?.errors;
+
+ const alertMessage = serverErrorMessage
+ ? sprintf(COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false)
+ : COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK;
+
return this.addAlert({
- message: __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- ),
+ message: alertMessage,
parent: formParentTimeline.get(0),
});
}
- updateNoteError() {
- createAlert({
- message: __(
- 'Your comment could not be updated! Please check your network connection and try again.',
- ),
+ updateNoteError(error, $editingNote) {
+ const serverErrorMessage = error?.response?.data?.errors;
+
+ const alertMessage = serverErrorMessage
+ ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage }, false)
+ : UPDATE_COMMENT_FORM.defaultError;
+
+ return this.addAlert({
+ message: alertMessage,
+ parent: $editingNote.get(0),
});
}
@@ -788,6 +799,8 @@ export default class Notes {
const $note = $target.closest('.note');
const $currentlyEditing = $('.note.is-editing:visible');
+ this.clearAlertWrapper();
+
if ($currentlyEditing.length) {
const isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
@@ -1777,7 +1790,7 @@ export default class Notes {
$form.trigger('ajax:success', [note]);
})
- .catch(() => {
+ .catch((error) => {
// Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove();
$submitBtn.prop('disabled', false);
@@ -1806,7 +1819,7 @@ export default class Notes {
$form.find('.js-note-text').val(formContentOriginal);
this.reenableTargetFormSubmitButton(e);
- this.addNoteError($form);
+ this.addNoteError(error, $form);
});
}
@@ -1854,14 +1867,14 @@ export default class Notes {
// Submission successful! render final note element
this.updateNote(data, $editingNote);
})
- .catch(() => {
+ .catch((error) => {
+ $editingNote.addClass('is-editing fade-in-full').removeClass('being-posted fade-in-half');
// Submission failed, revert back to original note
- $noteBodyText.html(escape(cachedNoteBodyText));
- $editingNote.removeClass('being-posted fade-in');
+ $noteBodyText.html(cachedNoteBodyText);
$editingNote.find('.gl-spinner').remove();
// Show Flash message about failure
- this.updateNoteError();
+ this.updateNoteError(error, $editingNote);
});
return $closeBtn.text($closeBtn.data('originalText'));
diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue
new file mode 100644
index 00000000000..890d7f80f8d
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue
@@ -0,0 +1,234 @@
+<script>
+import { GlButton, GlFormGroup, GlAlert, GlTooltipDirective } from '@gitlab/ui';
+
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+import updateDesignDescriptionMutation from '../../graphql/mutations/update_design_description.mutation.graphql';
+import { UPDATE_DESCRIPTION_ERROR } from '../../utils/error_messages';
+
+const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
+
+export default {
+ components: {
+ MarkdownEditor,
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ },
+ directives: {
+ SafeHtml,
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ edit: __('Edit'),
+ editDescription: s__('DesignManagement|Edit description'),
+ descriptionLabel: s__('DesignManagement|Design description'),
+ },
+ formFieldProps: {
+ id: 'design-description',
+ name: 'design-description',
+ placeholder: s__('DesignManagement|Write a comment or drag your files here…'),
+ 'aria-label': s__('DesignManagement|Design description'),
+ },
+ mixins: [glFeaturesFlagMixin()],
+ markdownDocsPath: helpPagePath('user/markdown'),
+ quickActionsDocsPath: helpPagePath('user/project/quick_actions'),
+ props: {
+ design: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ designVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ descriptionText: this.design.description || '',
+ showEditor: false,
+ isSubmitting: false,
+ errorMessage: '',
+ autosaveKey: `Issue/${getIdFromGraphQLId(this.design.issue.id)}/Design/${getIdFromGraphQLId(
+ this.design.id,
+ )}`,
+ };
+ },
+ computed: {
+ canUpdate() {
+ return this.design.issue?.userPermissions?.updateDesign && !this.showEditor;
+ },
+ },
+ watch: {
+ 'design.descriptionHtml': {
+ handler(newDescriptionHtml, oldDescriptionHtml) {
+ if (newDescriptionHtml !== oldDescriptionHtml) {
+ this.renderGFM();
+ }
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ startEditing() {
+ this.showEditor = true;
+ },
+ closeForm() {
+ this.showEditor = false;
+ },
+ async renderGFM() {
+ await this.$nextTick();
+ renderGFM(this.$refs['gfm-content']);
+
+ if (this.canUpdate) {
+ const checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
+
+ // enable boxes, disabled by default in markdown
+ checkboxes.forEach((checkbox) => {
+ // eslint-disable-next-line no-param-reassign
+ checkbox.disabled = false;
+ });
+ }
+ },
+ setDescriptionText(newText) {
+ // Do not update when cmd+enter is executed
+ if (!this.isSubmitting) {
+ this.descriptionText = newText;
+ }
+ },
+ async updateDesignDescription() {
+ this.isSubmitting = true;
+
+ try {
+ const designDescriptionInput = { description: this.descriptionText, id: this.design.id };
+
+ await this.$apollo.mutate({
+ mutation: updateDesignDescriptionMutation,
+ variables: {
+ input: designDescriptionInput,
+ },
+ });
+
+ this.closeForm();
+ } catch {
+ this.errorMessage = UPDATE_DESCRIPTION_ERROR;
+ } finally {
+ this.isSubmitting = false;
+ }
+ },
+ toggleCheckboxes(event) {
+ const { target } = event;
+
+ if (isCheckbox(target)) {
+ target.disabled = true;
+
+ const { sourcepos } = target.parentElement.dataset;
+
+ if (!sourcepos) return;
+
+ // Toggle checkboxes based on user input
+ this.descriptionText = toggleMarkCheckboxes({
+ rawMarkdown: this.descriptionText,
+ checkboxChecked: target.checked,
+ sourcepos,
+ });
+
+ // Update the desciption text using mutation
+ this.updateDesignDescription();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="design-description-container">
+ <gl-form-group
+ v-if="showEditor"
+ class="design-description-form common-note-form"
+ :label="$options.i18n.descriptionLabel"
+ >
+ <div v-if="errorMessage" class="gl-pb-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
+ <markdown-editor
+ :value="descriptionText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :form-field-props="$options.formFieldProps"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ :quick-actions-docs-path="$options.quickActionsDocsPath"
+ :autosave-key="autosaveKey"
+ enable-autocomplete
+ :supports-quick-actions="false"
+ autofocus
+ @input="setDescriptionText"
+ @keydown.meta.enter="updateDesignDescription"
+ @keydown.ctrl.enter="updateDesignDescription"
+ @keydown.exact.esc.stop="closeForm"
+ />
+ <div class="gl-display-flex gl-mt-3">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateDesignDescription"
+ >{{ s__('DesignManagement|Save') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="closeForm"
+ >{{ s__('DesignManagement|Cancel') }}
+ </gl-button>
+ </div>
+ </gl-form-group>
+ <div v-else class="design-description-view">
+ <div
+ class="design-description-header gl-display-flex gl-justify-content-space-between gl-mb-2"
+ >
+ <label class="gl-m-0">
+ {{ $options.i18n.descriptionLabel }}
+ </label>
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip
+ class="gl-ml-auto"
+ size="small"
+ data-testid="edit-description"
+ :aria-label="$options.i18n.editDescription"
+ @click="startEditing"
+ >
+ {{ $options.i18n.edit }}
+ </gl-button>
+ </div>
+ <div
+ v-if="!design.descriptionHtml"
+ data-testid="design-description-none"
+ class="gl-text-secondary gl-mb-5"
+ >
+ {{ s__('DesignManagement|None') }}
+ </div>
+ <div v-else class="design-description js-task-list-container">
+ <div
+ ref="gfm-content"
+ v-safe-html="design.descriptionHtml"
+ class="md gl-mb-4"
+ data-testid="design-description-content"
+ @change="toggleCheckboxes"
+ ></div>
+ </div>
+ </div>
+ </div>
+</template>
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 680a101b118..5affd448419 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
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlLink, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
@@ -43,6 +43,7 @@ export default {
ReplyPlaceholder,
TimeAgoTooltip,
ToggleRepliesWidget,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -311,7 +312,6 @@ export default {
:loading="isResolving"
category="tertiary"
data-testid="resolve-button"
- size="small"
@click.stop="toggleResolvedStatus"
/>
</template>
@@ -372,10 +372,13 @@ export default {
@cancel-form="hideForm"
>
<template v-if="discussion.resolvable" #resolve-checkbox>
- <label data-testid="resolve-checkbox">
- <input v-model="shouldChangeResolvedStatus" type="checkbox" />
+ <gl-form-checkbox
+ v-model="shouldChangeResolvedStatus"
+ class="gl-mt-5 gl-mb-n3"
+ data-testid="resolve-checkbox"
+ >
{{ resolveCheckboxText }}
- </label>
+ </gl-form-checkbox>
</template>
</design-reply-form>
</template>
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 b92a2392948..0eac2cad68d 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
@@ -3,8 +3,7 @@ import {
GlAvatar,
GlAvatarLink,
GlButton,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlLink,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -29,8 +28,7 @@ export default {
GlAvatar,
GlAvatarLink,
GlButton,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlLink,
TimeAgoTooltip,
TimelineEntryItem,
@@ -83,15 +81,38 @@ export default {
id: this.note.id,
};
},
- isEditButtonVisible() {
- return !this.isEditing && this.adminPermissions;
- },
- isMoreActionsButtonVisible() {
+ isEditingAndHasPermissions() {
return !this.isEditing && this.adminPermissions;
},
adminPermissions() {
return this.note.userPermissions.adminNote;
},
+ dropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.editCommentLabel,
+ action: () => {
+ this.isEditing = true;
+ },
+ extraAttrs: {
+ 'data-testid': 'delete-note-button',
+ 'data-qa-selector': 'delete_design_note_button',
+ class: 'gl-sm-display-none!',
+ },
+ },
+ {
+ text: this.$options.i18n.deleteCommentText,
+ action: () => {
+ this.$emit('delete-note', this.note);
+ },
+ extraAttrs: {
+ 'data-testid': 'delete-note-button',
+ 'data-qa-selector': 'delete_design_note_button',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
},
methods: {
hideForm() {
@@ -131,50 +152,41 @@ export default {
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<gl-link
- class="note-timestamp system-note-separator gl-display-block gl-mb-2"
+ class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm"
:href="`#note_${noteAnchorId}`"
>
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
</gl-link>
</span>
</div>
- <div class="gl-display-flex gl-align-items-baseline">
+ <div class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2">
<slot name="resolve-discussion"></slot>
<gl-button
- v-if="isEditButtonVisible"
+ v-if="isEditingAndHasPermissions"
v-gl-tooltip
+ class="gl-display-none gl-sm-display-inline-flex!"
:aria-label="$options.i18n.editCommentLabel"
:title="$options.i18n.editCommentLabel"
category="tertiary"
data-testid="note-edit"
icon="pencil"
- size="small"
@click="isEditing = true"
/>
- <gl-dropdown
- v-if="isMoreActionsButtonVisible"
+ <gl-disclosure-dropdown
+ v-if="isEditingAndHasPermissions"
v-gl-tooltip.hover
- class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ toggle-class="btn-sm"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="design_discussion_actions_ellipsis_dropdown"
data-testid="more-actions-dropdown"
- :text="$options.i18n.moreActionsLabel"
text-sr-only
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
no-caret
left
- >
- <gl-dropdown-item
- variant="danger"
- data-qa-selector="delete_design_note_button"
- data-testid="delete-note-button"
- @click="$emit('delete-note', note)"
- >
- {{ $options.i18n.deleteCommentText }}
- </gl-dropdown-item>
- </gl-dropdown>
+ :items="dropdownItems"
+ />
</div>
</div>
<template v-if="!isEditing">
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 4fd90130284..7474f8f3298 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -105,7 +105,7 @@ export default {
*/
this.$nextTick(() => {
if (!this.noteUpdateDirty) {
- this.autosaveDiscussion.reset();
+ this.autosaveDiscussion?.reset();
}
});
},
diff --git a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue
index 2e366282de3..189ddda525b 100644
--- a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue
@@ -39,31 +39,31 @@ export default {
<template>
<li
- class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3"
+ class="toggle-comments gl-bg-gray-50 gl-display-flex gl-align-items-center gl-py-3 gl-min-h-8"
:class="{ expanded: !collapsed }"
data-testid="toggle-comments-wrapper"
>
<gl-icon :name="iconName" class="gl-ml-3" @click.stop="$emit('toggle')" />
<gl-button
variant="link"
- class="toggle-comments-button gl-ml-2 gl-mr-2"
+ class="toggle-comments-button gl-ml-2 gl-mr-2 gl-font-sm!"
@click.stop="$emit('toggle')"
>
{{ toggleText }}
</gl-button>
<template v-if="collapsed">
- <span class="gl-text-gray-500">{{ __('Last reply by') }}</span>
+ <span class="gl-text-gray-500 gl-font-sm">{{ __('Last reply by') }}</span>
<gl-link
:href="lastReply.author.webUrl"
target="_blank"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2"
+ class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-font-sm gl-ml-2 gl-mr-2"
>
{{ lastReply.author.name }}
</gl-link>
<time-ago-tooltip
:time="lastReply.createdAt"
tooltip-placement="bottom"
- class="gl-text-gray-500"
+ class="gl-text-gray-500 gl-font-sm"
/>
</template>
</li>
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index c34d5cea0c2..9a8685f4c86 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -9,6 +9,7 @@ import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
import { extractDiscussions, extractParticipants } from '../utils/design_management_utils';
import DesignDiscussion from './design_notes/design_discussion.vue';
+import DescriptionForm from './design_description/description_form.vue';
import DesignNoteSignedOut from './design_notes/design_note_signed_out.vue';
import DesignTodoButton from './design_todo_button.vue';
@@ -21,6 +22,7 @@ export default {
GlAccordionItem,
GlSkeletonLoader,
DesignTodoButton,
+ DescriptionForm,
},
mixins: [glFeatureFlagsMixin()],
inject: {
@@ -54,6 +56,10 @@ export default {
type: Boolean,
required: true,
},
+ designVariables: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -143,6 +149,12 @@ export default {
:href="issue.webUrl"
>{{ issue.webPath }}</a
>
+ <description-form
+ v-if="!isLoading"
+ :design="design"
+ :design-variables="designVariables"
+ :markdown-preview-path="markdownPreviewPath"
+ />
<participants
:participants="discussionParticipants"
:show-participant-label="false"
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 9c1bcf5bf90..8339034fae9 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -128,10 +128,10 @@ export default {
params: { id: filename },
query: $route.query,
}"
- class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0"
>
<div
- class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
+ class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base"
>
<div
v-if="icon.name"
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql
index 9bd70e7e886..575201a7635 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql
@@ -5,6 +5,8 @@ fragment DesignListItem on Design {
notesCount
image
imageV432x230
+ description
+ descriptionHtml
currentUserTodos(state: pending) {
nodes {
id
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql
new file mode 100644
index 00000000000..78b66477747
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_design_description.mutation.graphql
@@ -0,0 +1,11 @@
+mutation updateDesignDescriptionMutation($input: DesignManagementUpdateInput!) {
+ designManagementUpdate(input: $input) {
+ errors
+ design {
+ id
+ image
+ description
+ descriptionHtml
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
index 730467c33f6..c6eda2797d5 100644
--- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql
@@ -25,6 +25,10 @@ query getDesign(
...Author
}
}
+ userPermissions {
+ createDesign
+ updateDesign
+ }
}
}
}
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index b182e68260a..5e520f791ad 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -43,7 +43,7 @@ export default {
});
this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
}
- if (this.designCollection.copyState === 'ERROR') {
+ if (this.designCollection?.copyState === 'ERROR') {
createAlert({
message: s__(
'DesignManagement|There was an error moving your designs. Please upload your designs below.',
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index eeb36e59b89..65e04b1ff98 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -385,6 +385,7 @@ export default {
</div>
<design-sidebar
:design="design"
+ :design-variables="designVariables"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:markdown-preview-path="markdownPreviewPath"
:is-loading="isLoading"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index dcc65c957fe..e7308aad785 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -38,6 +38,11 @@ import {
} from '../utils/error_messages';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
+export const i18n = {
+ dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'),
+ designLoadingError: __('An error occurred while loading designs. Please try again.'),
+};
+
export default {
components: {
GlLoadingIcon,
@@ -346,9 +351,7 @@ export default {
animation: 200,
ghostClass: 'gl-visibility-hidden',
},
- i18n: {
- dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'),
- },
+ i18n,
};
</script>
@@ -370,7 +373,7 @@ export default {
</gl-alert>
<header
v-if="showToolbar"
- class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!"
+ class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-top-base"
data-testid="design-toolbar-wrapper"
>
<div
@@ -427,7 +430,7 @@ export default {
<div :class="designContentWrapperClass">
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
- {{ __('An error occurred while loading designs. Please try again.') }}
+ {{ $options.i18n.designLoadingError }}
</gl-alert>
<header
v-else-if="isDesignCollectionCopying"
@@ -503,7 +506,7 @@ export default {
>
<design-dropzone
:enable-drag-behavior="isDraggingDesign"
- :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
+ :class="{ 'design-list-item': !isDesignListEmpty }"
:display-as-card="hasDesigns"
v-bind="$options.dropzoneProps"
data-qa-selector="design_dropzone_content"
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
index 7470f3d259b..2db34ea7103 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -61,6 +61,8 @@ export const designUploadOptimisticResponse = (files) => {
id: -uniqueId(),
image: '',
imageV432x230: '',
+ description: '',
+ descriptionHtml: '',
filename: file.name,
fullPath: '',
notesCount: 0,
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 1ed054abe22..2b5d04959b4 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -138,3 +138,7 @@ export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf(
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
);
+
+export const UPDATE_DESCRIPTION_ERROR = s__(
+ 'DesignManagement|Could not update description. Please try again.',
+);
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 02307150e2f..c0a9643e59e 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -90,22 +90,6 @@ export default {
ALERT_COLLAPSED_FILES,
},
props: {
- endpoint: {
- type: String,
- required: true,
- },
- endpointMetadata: {
- type: String,
- required: true,
- },
- endpointBatch: {
- type: String,
- required: true,
- },
- endpointDiffForPath: {
- type: String,
- required: true,
- },
endpointCoverage: {
type: String,
required: false,
@@ -116,15 +100,6 @@ export default {
required: false,
default: '',
},
- endpointUpdateUser: {
- type: String,
- required: false,
- default: '',
- },
- projectPath: {
- type: String,
- required: true,
- },
shouldShow: {
type: Boolean,
required: false,
@@ -144,51 +119,6 @@ export default {
required: false,
default: '',
},
- isFluidLayout: {
- type: Boolean,
- required: false,
- default: false,
- },
- dismissEndpoint: {
- type: String,
- required: false,
- default: '',
- },
- showSuggestPopover: {
- type: Boolean,
- required: false,
- default: false,
- },
- fileByFileUserPreference: {
- type: Boolean,
- required: false,
- default: false,
- },
- defaultSuggestionCommitMessage: {
- type: String,
- required: false,
- default: '',
- },
- rehydratedMrReviews: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- sourceProjectDefaultUrl: {
- type: String,
- required: false,
- default: '',
- },
- sourceProjectFullPath: {
- type: String,
- required: false,
- default: '',
- },
- isForked: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
const treeWidth =
@@ -325,7 +255,7 @@ export default {
this.adjustView();
},
viewDiffsFileByFile(newViewFileByFile) {
- if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) {
+ if (!newViewFileByFile && this.diffsIncomplete) {
this.refetchDiffData({ refetchMeta: false });
}
},
@@ -343,21 +273,6 @@ export default {
renderFileTree: 'adjustView',
},
mounted() {
- this.setBaseConfig({
- endpoint: this.endpoint,
- endpointMetadata: this.endpointMetadata,
- endpointBatch: this.endpointBatch,
- endpointDiffForPath: this.endpointDiffForPath,
- endpointCoverage: this.endpointCoverage,
- endpointUpdateUser: this.endpointUpdateUser,
- projectPath: this.projectPath,
- dismissEndpoint: this.dismissEndpoint,
- showSuggestPopover: this.showSuggestPopover,
- viewDiffsFileByFile: this.fileByFileUserPreference || false,
- defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- mrReviews: this.rehydratedMrReviews,
- });
-
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -467,26 +382,19 @@ export default {
subscribeToEvents() {
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
- if (this.glFeatures.singleFileFileByFile) {
- diffsEventHub.$on('diffFilesModified', this.setDiscussions);
- notesEventHub.$on('fetchedNotesData', this.rereadNoteHash);
- }
+ notesEventHub.$on('fetchedNotesData', this.rereadNoteHash);
+ diffsEventHub.$on('diffFilesModified', this.setDiscussions);
diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData);
},
unsubscribeFromEvents() {
diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData);
- if (this.glFeatures.singleFileFileByFile) {
- notesEventHub.$off('fetchedNotesData', this.rereadNoteHash);
- diffsEventHub.$off('diffFilesModified', this.setDiscussions);
- }
+ diffsEventHub.$off('diffFilesModified', this.setDiscussions);
+ notesEventHub.$off('fetchedNotesData', this.rereadNoteHash);
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
navigateToDiffFileNumber(number) {
- this.navigateToDiffFileIndex({
- index: number - 1,
- singleFile: this.glFeatures.singleFileFileByFile,
- });
+ this.navigateToDiffFileIndex(number - 1);
},
refetchDiffData({ refetchMeta = true } = {}) {
this.fetchData({ toggleTree: false, fetchMeta: refetchMeta });
@@ -506,7 +414,7 @@ export default {
if (data) {
realSize = data.real_size;
- if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) {
+ if (this.viewDiffsFileByFile) {
this.fetchFileByFile();
}
}
@@ -527,7 +435,7 @@ export default {
});
}
- if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) {
+ if (!this.viewDiffsFileByFile) {
this.fetchDiffFilesBatch()
.then(() => {
if (toggleTree) this.setTreeDisplay();
@@ -618,10 +526,7 @@ export default {
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) {
- this.goToFile({
- path: this.flatBlobsList[targetIndex].path,
- singleFile: this.glFeatures.singleFileFileByFile,
- });
+ this.goToFile({ path: this.flatBlobsList[targetIndex].path });
}
},
setTreeDisplay() {
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index d7b63d205dc..4d02fd80ba8 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapParallel } from 'ee_else_ce/diffs/components/diff_row_utils';
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
@@ -21,6 +21,7 @@ import ImageDiffOverlay from './image_diff_overlay.vue';
export default {
components: {
GlLoadingIcon,
+ GlButton,
DiffView,
DiffViewer,
NoteForm,
@@ -59,7 +60,10 @@ export default {
return this.diffFile.viewer.name;
},
isTextFile() {
- return this.diffViewerMode === diffViewerModes.text;
+ return this.diffViewerMode === diffViewerModes.text && !this.diffFile.viewer.whitespace_only;
+ },
+ isWhitespaceOnly() {
+ return this.diffFile.viewer.whitespace_only;
},
noPreview() {
return this.diffViewerMode === diffViewerModes.no_preview;
@@ -71,7 +75,10 @@ export default {
return this.getCommentFormForDiffFile(this.diffFileHash);
},
showNotesContainer() {
- return this.imageDiscussions.length || this.diffFileCommentForm;
+ return (
+ this.diffViewerMode === diffViewerModes.image &&
+ (this.imageDiscussionsWithDrafts.length || this.diffFileCommentForm)
+ );
},
diffFileHash() {
return this.diffFile.file_hash;
@@ -83,6 +90,11 @@ export default {
// TODO: Do this data generation when we receive a response to save a computed property being created
return this.diffLines(this.diffFile).map(mapParallel(this)) || [];
},
+ imageDiscussions() {
+ return this.diffFile.discussions.filter(
+ (f) => f.position?.position_type === IMAGE_DIFF_POSITION_TYPE,
+ );
+ },
},
updated() {
this.$nextTick(() => {
@@ -107,6 +119,7 @@ export default {
});
},
},
+ IMAGE_DIFF_POSITION_TYPE,
};
</script>
@@ -122,6 +135,23 @@ export default {
/>
<gl-loading-icon v-if="diffFile.renderingLines" size="lg" class="mt-3" />
</template>
+ <div
+ v-else-if="isWhitespaceOnly"
+ class="gl-bg-gray-10 gl--flex-center gl-h-13"
+ data-testid="diff-whitespace-only-state"
+ >
+ {{ __('Contains only whitespace changes.') }}
+ <gl-button
+ category="tertiary"
+ variant="info"
+ size="small"
+ class="gl-ml-3"
+ data-testid="diff-load-file-button"
+ @click="$emit('load-file', { w: '0' })"
+ >
+ {{ __('Show changes') }}
+ </gl-button>
+ </div>
<not-diffable-viewer v-else-if="notDiffable" />
<no-preview-viewer v-else-if="noPreview" />
<diff-viewer
@@ -160,13 +190,17 @@ export default {
class="d-none d-sm-block new-comment"
/>
<diff-discussions
- v-if="diffFile.discussions.length"
+ v-if="imageDiscussions.length"
class="diff-file-discussions"
- :discussions="diffFile.discussions"
+ :discussions="imageDiscussions"
should-collapse-discussions
render-avatar-badge
/>
- <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" />
+ <diff-file-drafts
+ :file-hash="diffFileHash"
+ :position-type="$options.IMAGE_DIFF_POSITION_TYPE"
+ class="diff-file-discussions"
+ />
<note-form
v-if="diffFileCommentForm"
ref="noteForm"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 4c2cb83ffb3..8e1c6cecbd1 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -12,6 +12,9 @@ import { scrollToElement } from '~/lib/utils/common_utils';
import { sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
+import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
+import NoteForm from '~/notes/components/note_form.vue';
+import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
@@ -19,10 +22,12 @@ import {
EVT_EXPAND_ALL_FILES,
EVT_PERF_MARK_DIFF_FILES_END,
EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
+ FILE_DIFF_POSITION_TYPE,
} from '../constants';
import eventHub from '../event_hub';
import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n';
import { collapsedType, getShortShaFromFile } from '../utils/diff_file';
+import DiffDiscussions from './diff_discussions.vue';
import DiffFileHeader from './diff_file_header.vue';
export default {
@@ -33,11 +38,18 @@ export default {
GlLoadingIcon,
GlSprintf,
GlAlert,
+ DiffFileDrafts,
+ NoteForm,
+ DiffDiscussions,
},
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.file.file_hash })],
+ mixins: [
+ glFeatureFlagsMixin(),
+ IdState({ idProp: (vm) => vm.file.file_hash }),
+ diffLineNoteFormMixin,
+ ],
props: {
file: {
type: Object,
@@ -101,7 +113,7 @@ export default {
'conflictResolutionPath',
'canMerge',
]),
- ...mapGetters(['isNotesFetched']),
+ ...mapGetters(['isNotesFetched', 'getNoteableData', 'noteableType']),
...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']),
viewBlobHref() {
return escape(this.file.view_path);
@@ -175,6 +187,21 @@ export default {
return this.file.viewer?.manuallyCollapsed;
},
+ fileDiscussions() {
+ return this.file.discussions.filter(
+ (f) => f.position?.position_type === FILE_DIFF_POSITION_TYPE,
+ );
+ },
+ showFileDiscussions() {
+ return (
+ this.glFeatures.commentOnFiles &&
+ !this.file.viewer?.manuallyCollapsed &&
+ (this.fileDiscussions.length || this.file.drafts.length || this.file.hasCommentForm)
+ );
+ },
+ diffFileHash() {
+ return this.file.file_hash;
+ },
},
watch: {
'file.id': {
@@ -187,6 +214,9 @@ export default {
'file.file_hash': {
handler: function hashChangeWatch(newHash, oldHash) {
if (
+ this.viewDiffsFileByFile &&
+ !this.isCollapsed &&
+ !this.glFeatures.singleFileFileByFile &&
newHash &&
oldHash &&
!this.hasDiff &&
@@ -209,12 +239,6 @@ export default {
if (this.hasDiff) {
this.postRender();
- } else if (
- this.viewDiffsFileByFile &&
- !this.isCollapsed &&
- !this.glFeatures.singleFileFileByFile
- ) {
- this.requestDiff();
}
this.manageViewedEffects();
@@ -230,6 +254,8 @@ export default {
'assignDiscussionsToDiff',
'setRenderIt',
'setFileCollapsedByUser',
+ 'saveDiffDiscussion',
+ 'toggleFileCommentForm',
]),
manageViewedEffects() {
if (
@@ -281,12 +307,12 @@ export default {
this.requestDiff();
}
},
- requestDiff() {
+ requestDiff(params = {}) {
const { idState, file } = this;
idState.isLoadingCollapsedDiff = true;
- this.loadCollapsedDiff(file)
+ this.loadCollapsedDiff({ file, params })
.then(() => {
idState.isLoadingCollapsedDiff = false;
idState.hasLoadedCollapsedDiff = true;
@@ -319,8 +345,20 @@ export default {
hideForkMessage() {
this.idState.forkMessageVisible = false;
},
+ handleSaveNote(note) {
+ this.saveDiffDiscussion({
+ note,
+ formData: {
+ noteableData: this.getNoteableData,
+ noteableType: this.noteableType,
+ diffFile: this.file,
+ positionType: FILE_DIFF_POSITION_TYPE,
+ },
+ });
+ },
},
CONFLICT_TEXT,
+ FILE_DIFF_POSITION_TYPE,
};
</script>
@@ -425,6 +463,35 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
+ <div v-if="showFileDiscussions" class="gl-border-b" data-testid="file-discussions">
+ <div class="diff-file-discussions-wrapper">
+ <diff-discussions
+ v-if="fileDiscussions.length"
+ class="diff-file-discussions"
+ data-testid="diff-file-discussions"
+ :discussions="fileDiscussions"
+ />
+ <diff-file-drafts
+ :file-hash="file.file_hash"
+ :show-pin="false"
+ :position-type="$options.FILE_DIFF_POSITION_TYPE"
+ class="diff-file-discussions"
+ />
+ <note-form
+ v-if="file.hasCommentForm"
+ :save-button-title="__('Comment')"
+ :diff-file="file"
+ autofocus
+ class="gl-py-3 gl-px-5"
+ data-testid="file-note-form"
+ @handleFormUpdate="handleSaveNote"
+ @handleFormUpdateAddToReview="
+ (note) => addToReview(note, $options.FILE_DIFF_POSITION_TYPE)
+ "
+ @cancelForm="toggleFileCommentForm(file.file_path)"
+ />
+ </div>
+ </div>
<gl-loading-icon
v-if="showLoadingIcon"
size="sm"
@@ -464,6 +531,7 @@ export default {
:class="hasBodyClasses.content"
:diff-file="file"
:help-page-path="helpPagePath"
+ @load-file="requestDiff"
/>
</template>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 792be3de1e5..494a20045f7 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -115,6 +115,7 @@ export default {
computed: {
...mapState('diffs', ['latestDiff']),
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
+ ...mapGetters(['getNoteableData']),
diffContentIDSelector() {
return `#diff-content-${this.diffFile.file_hash}`;
},
@@ -210,6 +211,9 @@ export default {
labelToggleFile() {
return this.expanded ? __('Hide file contents') : __('Show file contents');
},
+ showCommentButton() {
+ return this.getNoteableData.current_user.can_create_note && this.glFeatures.commentOnFiles;
+ },
},
watch: {
'idState.moreActionsShown': {
@@ -233,6 +237,7 @@ export default {
'reviewFile',
'setFileCollapsedByUser',
'setGenerateTestFilePath',
+ 'toggleFileCommentForm',
]),
handleToggleFile() {
this.$emit('toggleFile');
@@ -389,6 +394,18 @@ export default {
>
{{ $options.i18n.fileReviewLabel }}
</gl-form-checkbox>
+ <gl-button
+ v-if="showCommentButton"
+ v-gl-tooltip.hover
+ :title="__('Comment on this file')"
+ :aria-label="__('Comment on this file')"
+ icon="comment"
+ category="tertiary"
+ size="small"
+ class="gl-mr-3 btn-icon"
+ data-testid="comment-files-button"
+ @click="toggleFileCommentForm(diffFile.file_path)"
+ />
<gl-button-group class="gl-pt-0!">
<gl-button
v-if="diffFile.external_url"
@@ -445,7 +462,10 @@ export default {
v-if="showGenerateTestFileButton"
@click="setGenerateTestFilePath(diffFile.new_path)"
>
- {{ __('Generate test with AI') }}
+ <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ {{ __('Suggest test cases') }}
+ <gl-icon name="tanuki-ai" class="gl-text-purple-600 gl-mr-n3" />
+ </span>
</gl-dropdown-item>
<gl-dropdown-item
v-if="diffFile.replaced_view_path"
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 43ba527dad8..9ddf5b51c9a 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -240,6 +240,7 @@ export default {
:show-suggest-popover="showSuggestPopover"
:save-button-title="__('Comment')"
:autosave-key="autosaveKey"
+ :autofocus="false"
class="diff-comment-form gl-mt-3"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 348d6d1d78d..7c87ea1cbf2 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
+import { throttle } from 'lodash';
import { IdState } from 'vendor/vue-virtual-scroller';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
@@ -77,6 +78,9 @@ export default {
return this.codequalityDiff?.files?.[this.diffFile.file_path]?.length > 0;
},
},
+ created() {
+ this.onDragOverThrottled = throttle((line) => this.onDragOver(line), 100, { leading: true });
+ },
methods: {
...mapActions(['setSelectedCommentPosition']),
...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
@@ -255,7 +259,7 @@ export default {
({ lineCode, expanded }) =>
toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
"
- @enterdragging="onDragOver"
+ @enterdragging="onDragOverThrottled"
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
index da880c6f3ca..2cffe928d7b 100644
--- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
+++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
@@ -30,8 +30,8 @@ export default {
ALLOWED_ATTR: ['href', 'rel'],
},
computed: {
- drawerOffsetTop() {
- return getContentWrapperHeight('.content-wrapper');
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
},
},
DRAWER_Z_INDEX,
@@ -47,7 +47,7 @@ export default {
</script>
<template>
<gl-drawer
- :header-height="drawerOffsetTop"
+ :header-height="getDrawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
class="findings-drawer"
:open="Object.keys(drawer).length !== 0"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 4f1875e9175..b9bfceee6b4 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -5,11 +5,13 @@ import micromatch from 'micromatch';
import { debounce } from 'lodash';
import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RecycleScroller } from 'vendor/vue-virtual-scroller';
+import { contentTop } from '~/lib/utils/common_utils';
import DiffFileRow from './diff_file_row.vue';
const MODIFIER_KEY = getModifierKey();
+const MAX_ITEMS_ON_NARROW_SCREEN = 8;
+const BOTTOM_MARGIN = 16;
export default {
directives: {
@@ -20,7 +22,6 @@ export default {
DiffFileRow,
RecycleScroller,
},
- mixins: [glFeatureFlagsMixin()],
props: {
hideFileStats: {
type: Boolean,
@@ -31,13 +32,16 @@ export default {
return {
search: '',
scrollerHeight: 0,
- resizeObserver: null,
rowHeight: 0,
debouncedHeightCalc: null,
+ reviewBarHeight: 0,
+ largeBreakpointSize: 0,
};
},
computed: {
...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']),
+ ...mapState('batchComments', ['reviewBarRendered']),
+ ...mapGetters('batchComments', ['draftsCount']),
...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
let search = this.search.toLowerCase().trim();
@@ -90,21 +94,44 @@ export default {
return result;
},
+ reviewBarEnabled() {
+ return this.draftsCount > 0;
+ },
+ },
+ watch: {
+ reviewBarEnabled() {
+ this.debouncedHeightCalc();
+ },
+ calculateReviewBarHeight() {
+ this.debouncedHeightCalc();
+ },
},
created() {
this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50);
},
mounted() {
const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height');
+ const breakpointProp = getComputedStyle(window.document.body).getPropertyValue(
+ '--breakpoint-lg',
+ );
+ this.largeBreakpointSize = parseInt(breakpointProp, 10);
this.rowHeight = parseInt(heightProp, 10);
this.calculateScrollerHeight();
- this.resizeObserver = new ResizeObserver(() => {
- this.debouncedHeightCalc();
- });
- this.resizeObserver.observe(this.$refs.scrollRoot);
+ let stop;
+ // eslint-disable-next-line prefer-const
+ stop = this.$watch(
+ () => this.reviewBarRendered,
+ (enabled) => {
+ if (!enabled) return;
+ this.calculateReviewBarHeight();
+ stop();
+ },
+ { immediate: true },
+ );
+ window.addEventListener('resize', this.debouncedHeightCalc, { passive: true });
},
beforeDestroy() {
- this.resizeObserver.disconnect();
+ window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true });
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
@@ -112,7 +139,20 @@ export default {
this.search = '';
},
calculateScrollerHeight() {
- this.scrollerHeight = this.$refs.scrollRoot.clientHeight;
+ if (window.matchMedia(`(max-width: ${this.largeBreakpointSize - 1}px)`).matches) {
+ this.calculateMobileScrollerHeight();
+ } else {
+ let clipping = BOTTOM_MARGIN;
+ if (this.reviewBarEnabled) clipping += this.reviewBarHeight;
+ this.scrollerHeight = this.$refs.scrollRoot.clientHeight - clipping;
+ }
+ },
+ calculateMobileScrollerHeight() {
+ const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.flatFilteredTreeList.length);
+ this.scrollerHeight = Math.min(maxItems * this.rowHeight, window.innerHeight - contentTop());
+ },
+ calculateReviewBarHeight() {
+ this.reviewBarHeight = document.querySelector('.js-review-bar')?.offsetHeight || 0;
},
},
searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
@@ -130,7 +170,7 @@ export default {
>
<div class="gl-pb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
- <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" />
+ <gl-icon name="search" class="gl-absolute gl-top-3 gl-left-3 tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
id="diff-tree-search"
@@ -149,7 +189,7 @@ export default {
class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
@click="clearSearch"
>
- <gl-icon name="close" />
+ <gl-icon name="close" class="gl-absolute gl-top-3 gl-right-1 tree-list-icon" />
</button>
</div>
</div>
@@ -177,7 +217,7 @@ export default {
:class="{ 'tree-list-parent': item.level > 0 }"
class="gl-relative"
@toggleTreeOpen="toggleTreeOpen"
- @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })"
+ @clickFile="(path) => goToFile({ path })"
/>
</template>
<template #after>
@@ -196,13 +236,6 @@ export default {
margin-left: 12px;
}
-.diff-tree-search-shortcut {
- top: 50%;
- right: 10px;
- transform: translateY(-50%);
- pointer-events: none;
-}
-
.tree-list-icon:not(button) {
pointer-events: none;
}
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 063e36fa7fb..575cd05ceb8 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -12,6 +12,7 @@ export const NEW_LINE_TYPE = 'new';
export const OLD_LINE_TYPE = 'old';
export const TEXT_DIFF_POSITION_TYPE = 'text';
export const IMAGE_DIFF_POSITION_TYPE = 'image';
+export const FILE_DIFF_POSITION_TYPE = 'file';
export const LINE_POSITION_LEFT = 'left';
export const LINE_POSITION_RIGHT = 'right';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 53c27632c4f..29cf90dcbe2 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -9,8 +9,6 @@ import eventHub from '../notes/event_hub';
import DiffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants';
-import { getReviewsForMergeRequest } from './utils/file_reviews';
-import { getDerivedMergeRequestInformation } from './utils/merge_request';
export default function initDiffsApp(store = notesStore) {
const el = document.getElementById('js-diffs-app');
@@ -32,26 +30,13 @@ export default function initDiffsApp(store = notesStore) {
},
data() {
return {
- endpoint: dataset.endpoint,
- endpointMetadata: dataset.endpointMetadata || '',
- endpointBatch: dataset.endpointBatch || '',
- endpointDiffForPath: dataset.endpointDiffForPath || '',
endpointCoverage: dataset.endpointCoverage || '',
endpointCodequality: dataset.endpointCodequality || '',
- endpointUpdateUser: dataset.updateCurrentUserPath,
- projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
- isFluidLayout: parseBoolean(dataset.isFluidLayout),
dismissEndpoint: dataset.dismissEndpoint,
- showSuggestPopover: parseBoolean(dataset.showSuggestPopover),
showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault),
- viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault),
- defaultSuggestionCommitMessage: dataset.defaultSuggestionCommitMessage,
- sourceProjectDefaultUrl: dataset.sourceProjectDefaultUrl,
- sourceProjectFullPath: dataset.sourceProjectFullPath,
- isForked: parseBoolean(dataset.isForked),
};
},
computed: {
@@ -90,31 +75,14 @@ export default function initDiffsApp(store = notesStore) {
...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']),
},
render(createElement) {
- const { mrPath } = getDerivedMergeRequestInformation({ endpoint: this.endpoint });
-
return createElement('diffs-app', {
props: {
- endpoint: this.endpoint,
- endpointMetadata: this.endpointMetadata,
- endpointBatch: this.endpointBatch,
- endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointCodequality: this.endpointCodequality,
- endpointUpdateUser: this.endpointUpdateUser,
currentUser: this.currentUser,
- projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
- isFluidLayout: this.isFluidLayout,
- dismissEndpoint: this.dismissEndpoint,
- showSuggestPopover: this.showSuggestPopover,
- fileByFileUserPreference: this.viewDiffsFileByFile,
- defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
- rehydratedMrReviews: getReviewsForMergeRequest(mrPath),
- sourceProjectDefaultUrl: this.sourceProjectDefaultUrl,
- sourceProjectFullPath: this.sourceProjectFullPath,
- isForked: this.isForked,
},
});
},
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
index d41bb160e96..5ca9ade668c 100644
--- a/app/assets/javascripts/diffs/mixins/draft_comments.js
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -1,4 +1,5 @@
import { mapGetters } from 'vuex';
+import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
export default {
computed: {
@@ -10,8 +11,10 @@ export default {
'hasParallelDraftLeft',
'hasParallelDraftRight',
]),
- imageDiscussions() {
- return this.diffFile.discussions.concat(this.draftsForFile(this.diffFile.file_hash));
+ imageDiscussionsWithDrafts() {
+ return this.diffFile.discussions
+ .filter((f) => f.position?.position_type === IMAGE_DIFF_POSITION_TYPE)
+ .concat(this.draftsForFile(this.diffFile.file_hash));
},
},
};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 0668551902a..029be6ebad9 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -50,6 +50,7 @@ import {
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
EVT_MR_PREPARED,
+ FILE_DIFF_POSITION_TYPE,
} from '../constants';
import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n';
import eventHub from '../event_hub';
@@ -461,6 +462,26 @@ export const setParallelDiffViewType = ({ commit }) => {
export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
commit(types.TOGGLE_LINE_HAS_FORM, { lineCode, fileHash, hasForm: true });
+
+ // The comment form for diffs gets focussed differently due to the way the virtual scroller
+ // works. If we focus the comment form on mount and the comment form gets removed and then
+ // added again the page will scroll in unexpected ways
+ setTimeout(() => {
+ const el = document.querySelector(`[data-line-code="${lineCode}"] textarea`);
+
+ if (!el) return;
+
+ const { bottom } = el.getBoundingClientRect();
+ const overflowBottom = bottom - window.innerHeight;
+
+ // Prevent the browser scrolling for us
+ // We handle the scrolling to not break the diffs virtual scroller
+ el.focus({ preventScroll: true });
+
+ if (overflowBottom > 0) {
+ window.scrollBy(0, Math.floor(Math.abs(overflowBottom)) + 150);
+ }
+ });
};
export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => {
@@ -505,11 +526,12 @@ export const scrollToLineIfNeededParallel = (_, line) => {
}
};
-export const loadCollapsedDiff = ({ commit, getters, state }, file) => {
+export const loadCollapsedDiff = ({ commit, getters, state }, { file, params = {} }) => {
const versionPath = state.mergeRequestDiff?.version_path;
const loadParams = {
commit_id: getters.commitId,
w: state.showWhitespace ? '0' : '1',
+ ...params,
};
if (versionPath) {
@@ -577,6 +599,7 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }
const postData = getNoteFormData({
commit: state.commit,
note,
+ showWhitespace: state.showWhitespace,
...formData,
});
@@ -592,6 +615,11 @@ export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }
.then((discussion) => dispatch('assignDiscussionsToDiff', [discussion]))
.then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
+ .then(() => {
+ if (formData.positionType === FILE_DIFF_POSITION_TYPE) {
+ dispatch('toggleFileCommentForm', formData.diffFile.file_path);
+ }
+ })
.catch(() =>
createAlert({
message: s__('MergeRequests|Saving the comment failed'),
@@ -607,8 +635,8 @@ export const setCurrentFileHash = ({ commit }, hash) => {
commit(types.SET_CURRENT_DIFF_FILE, hash);
};
-export const goToFile = ({ state, commit, dispatch, getters }, { path, singleFile }) => {
- if (!state.viewDiffsFileByFile || !singleFile) {
+export const goToFile = ({ state, commit, dispatch, getters }, { path }) => {
+ if (!state.viewDiffsFileByFile) {
dispatch('scrollToFile', { path });
} else {
if (!state.treeEntries[path]) return;
@@ -809,7 +837,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) =
commit(types.REQUEST_FULL_DIFF, filePath);
if (file.isShowingFullFile) {
- dispatch('loadCollapsedDiff', file)
+ dispatch('loadCollapsedDiff', { file })
.then(() => dispatch('assignDiscussionsToDiff', getters.getDiffFileDiscussions(file)))
.catch(() => dispatch('receiveFullDiffError', filePath));
} else {
@@ -942,16 +970,13 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n
}
};
-export const navigateToDiffFileIndex = (
- { state, getters, commit, dispatch },
- { index, singleFile },
-) => {
+export const navigateToDiffFileIndex = ({ state, getters, commit, dispatch }, index) => {
const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
- if (state.viewDiffsFileByFile && singleFile) {
+ if (state.viewDiffsFileByFile) {
dispatch('fetchFileByFile');
}
};
@@ -993,3 +1018,9 @@ export function reviewFile({ commit, state }, { file, reviewed = true }) {
}
export const disableVirtualScroller = ({ commit }) => commit(types.DISABLE_VIRTUAL_SCROLLING);
+
+export const toggleFileCommentForm = ({ commit }, filePath) =>
+ commit(types.TOGGLE_FILE_COMMENT_FORM, filePath);
+
+export const addDraftToFile = ({ commit }, { filePath, draft }) =>
+ commit(types.ADD_DRAFT_TO_FILE, { filePath, draft });
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 10a6a872fe4..a8a831fb269 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -63,9 +63,12 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => (diff) => {
* @returns {Boolean}
*/
export const diffHasExpandedDiscussions = () => (diff) => {
- return diff[INLINE_DIFF_LINES_KEY].filter((l) => l.discussions.length >= 1).some(
- (l) => l.discussionsExpanded,
- );
+ const diffLineDiscussionsExpanded = diff[INLINE_DIFF_LINES_KEY].filter(
+ (l) => l.discussions.length >= 1,
+ ).some((l) => l.discussionsExpanded);
+ const diffFileDiscussionsExpanded = diff.discussions?.some((d) => d.expanded);
+
+ return diffFileDiscussionsExpanded || diffLineDiscussionsExpanded;
};
/**
@@ -74,7 +77,10 @@ export const diffHasExpandedDiscussions = () => (diff) => {
* @returns {Boolean}
*/
export const diffHasDiscussions = () => (diff) => {
- return diff[INLINE_DIFF_LINES_KEY].some((l) => l.discussions.length >= 1);
+ return (
+ diff.discussions?.length >= 1 ||
+ diff[INLINE_DIFF_LINES_KEY].some((l) => l.discussions.length >= 1)
+ );
};
/**
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 51c21c1bfc4..c32d82faad0 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -49,3 +49,6 @@ export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
export const DISABLE_VIRTUAL_SCROLLING = 'DISABLE_VIRTUAL_SCROLLING';
+
+export const TOGGLE_FILE_COMMENT_FORM = 'TOGGLE_FILE_COMMENT_FORM';
+export const ADD_DRAFT_TO_FILE = 'ADD_DRAFT_TO_FILE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 5e7fe8b5cd8..2786e971f4b 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -5,6 +5,7 @@ import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
INLINE_DIFF_LINES_KEY,
EXPANDED_LINE_TYPE,
+ FILE_DIFF_POSITION_TYPE,
} from '../constants';
import * as types from './mutation_types';
import {
@@ -168,6 +169,7 @@ export default {
const { latestDiff } = state;
const originalStartLineCode = discussion.original_position?.line_range?.start?.line_code;
+ const positionType = discussion.position?.position_type;
const discussionLineCodes = [
discussion.line_code,
originalStartLineCode,
@@ -212,16 +214,7 @@ export default {
state.diffFiles.forEach((file) => {
if (file.file_hash === fileHash) {
- if (file[INLINE_DIFF_LINES_KEY].length) {
- file[INLINE_DIFF_LINES_KEY].forEach((line) => {
- Object.assign(
- line,
- setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
- );
- });
- }
-
- if (!file[INLINE_DIFF_LINES_KEY].length) {
+ if (positionType === FILE_DIFF_POSITION_TYPE) {
const newDiscussions = (file.discussions || [])
.filter((d) => d.id !== discussion.id)
.concat(discussion);
@@ -229,6 +222,25 @@ export default {
Object.assign(file, {
discussions: newDiscussions,
});
+ } else {
+ if (file[INLINE_DIFF_LINES_KEY].length) {
+ file[INLINE_DIFF_LINES_KEY].forEach((line) => {
+ Object.assign(
+ line,
+ setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
+ );
+ });
+ }
+
+ if (!file[INLINE_DIFF_LINES_KEY].length) {
+ const newDiscussions = (file.discussions || [])
+ .filter((d) => d.id !== discussion.id)
+ .concat(discussion);
+
+ Object.assign(file, {
+ discussions: newDiscussions,
+ });
+ }
}
}
});
@@ -378,4 +390,14 @@ export default {
[types.DISABLE_VIRTUAL_SCROLLING](state) {
state.disableVirtualScroller = true;
},
+ [types.TOGGLE_FILE_COMMENT_FORM](state, filePath) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.hasCommentForm = !file.hasCommentForm;
+ },
+ [types.ADD_DRAFT_TO_FILE](state, { filePath, draft }) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file?.drafts.push(draft);
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 4ca353333b7..68536d36ac0 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -140,6 +140,7 @@ export function getFormData(params) {
linePosition,
positionType,
lineRange,
+ showWhitespace,
} = params;
const position = JSON.stringify({
@@ -156,6 +157,7 @@ export function getFormData(params) {
width: params.width,
height: params.height,
line_range: lineRange,
+ ignore_whitespace_change: !showWhitespace,
});
const postData = {
@@ -486,9 +488,8 @@ export const getDiffMode = (diffFile) => {
const diffModeKey = Object.keys(diffModes).find((key) => diffFile[`${key}_file`]);
return (
diffModes[diffModeKey] ||
- (diffFile.viewer &&
- diffFile.viewer.name === diffViewerModes.mode_changed &&
- diffViewerModes.mode_changed) ||
+ (diffFile.viewer?.name === diffViewerModes.mode_changed && diffViewerModes.mode_changed) ||
+ (diffFile.viewer?.name === diffViewerModes.no_preview && diffViewerModes.no_preview) ||
diffModes.replaced
);
};
diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index e2fb24f7b57..f2a3224d332 100644
--- a/app/assets/javascripts/diffs/utils/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -53,6 +53,9 @@ export const isNotDiffable = (file) => file?.viewer?.name === viewerModes.not_di
export function prepareRawDiffFile({ file, allFiles, meta = false, index = -1 }) {
const additionalProperties = {
brokenSymlink: fileSymlinkInformation(file, allFiles),
+ hasCommentForm: false,
+ discussions: file.discussions || [],
+ drafts: [],
viewer: {
...file.viewer,
...collapsed(file),
diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js
index 2e1e074db3b..2f1870087f9 100644
--- a/app/assets/javascripts/drawio/constants.js
+++ b/app/assets/javascripts/drawio/constants.js
@@ -1,9 +1,18 @@
/*
* TODO: Make this URL configurable
*/
-export const DRAWIO_EDITOR_URL =
- 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; // TODO Make it configurable
-
+export const DRAWIO_PARAMS = {
+ ui: 'sketch',
+ noSaveBtn: 1,
+ saveAndExit: 1,
+ keepmodified: 1,
+ spin: 1,
+ embed: 1,
+ libraries: 1,
+ configure: 1,
+ proto: 'json',
+ toSvg: 1,
+};
export const DRAWIO_FRAME_ID = 'drawio-frame';
export const DARK_BACKGROUND_COLOR = '#202020';
diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js
index 38d1cadcc63..3c411d8093c 100644
--- a/app/assets/javascripts/drawio/drawio_editor.js
+++ b/app/assets/javascripts/drawio/drawio_editor.js
@@ -4,8 +4,8 @@ import { darkModeEnabled } from '~/lib/utils/color_utils';
import { __ } from '~/locale';
import { setAttributes } from '~/lib/utils/dom_utils';
import {
+ DRAWIO_PARAMS,
DARK_BACKGROUND_COLOR,
- DRAWIO_EDITOR_URL,
DRAWIO_FRAME_ID,
DIAGRAM_BACKGROUND_COLOR,
DRAWIO_IFRAME_TIMEOUT,
@@ -17,7 +17,7 @@ function updateDrawioEditorState(drawIOEditorState, data) {
}
function postMessageToDrawioEditor(drawIOEditorState, message) {
- const { origin } = new URL(DRAWIO_EDITOR_URL);
+ const { origin } = new URL(drawIOEditorState.drawioUrl);
drawIOEditorState.iframe.contentWindow.postMessage(JSON.stringify(message), origin);
}
@@ -222,7 +222,7 @@ function createEditorIFrame(drawIOEditorState) {
setAttributes(iframe, {
id: DRAWIO_FRAME_ID,
- src: DRAWIO_EDITOR_URL,
+ src: drawIOEditorState.drawioUrl,
class: 'drawio-editor',
});
@@ -256,7 +256,7 @@ function attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade) {
});
}
-const createDrawioEditorState = ({ filename = null }) => ({
+const createDrawioEditorState = ({ filename = null, drawioUrl }) => ({
newDiagram: true,
filename,
diagramSvg: null,
@@ -266,10 +266,17 @@ const createDrawioEditorState = ({ filename = null }) => ({
initialized: false,
dark: darkModeEnabled(),
disposeEventListener: null,
+ drawioUrl,
});
-export function launchDrawioEditor({ editorFacade, filename }) {
- const drawIOEditorState = createDrawioEditorState({ filename });
+export function launchDrawioEditor({ editorFacade, filename, drawioUrl = gon.diagramsnet_url }) {
+ const url = new URL(drawioUrl);
+
+ for (const [key, value] of Object.entries(DRAWIO_PARAMS)) {
+ url.searchParams.set(key, value);
+ }
+
+ const drawIOEditorState = createDrawioEditorState({ filename, drawioUrl: url.href });
// The execution order of these two functions matter
attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade);
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
index 0afee7bebe0..f2550d753d6 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -1,6 +1,5 @@
<script>
import { isEmpty } from 'lodash';
-import { GlButtonGroup } from '@gitlab/ui';
import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
import { EDITOR_TOOLBAR_BUTTON_GROUPS } from '~/editor/constants';
import SourceEditorToolbarButton from './source_editor_toolbar_button.vue';
@@ -9,7 +8,6 @@ export default {
name: 'SourceEditorToolbar',
components: {
SourceEditorToolbarButton,
- GlButtonGroup,
},
data() {
return {
@@ -52,31 +50,34 @@ export default {
<section
v-if="isVisible"
id="se-toolbar"
- class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center"
+ class="file-buttons gl-display-flex gl-align-items-center gl-justify-content-end"
>
- <gl-button-group v-if="hasGroupItems($options.groups.file)">
+ <div v-if="hasGroupItems($options.groups.file)">
<source-editor-toolbar-button
v-for="item in getGroupItems($options.groups.file)"
:key="item.id"
:button="item"
@click="$emit('click', item)"
/>
- </gl-button-group>
- <gl-button-group v-if="hasGroupItems($options.groups.edit)">
+ </div>
+ <div
+ v-if="hasGroupItems($options.groups.edit)"
+ class="md-header-toolbar gl-display-flex gl-flex-wrap gl-gap-3 gl-ml-auto"
+ >
<source-editor-toolbar-button
v-for="item in getGroupItems($options.groups.edit)"
:key="item.id"
:button="item"
@click="$emit('click', item)"
/>
- </gl-button-group>
- <gl-button-group v-if="hasGroupItems($options.groups.settings)" class="gl-ml-auto">
+ </div>
+ <div v-if="hasGroupItems($options.groups.settings)" class="gl-align-self-start">
<source-editor-toolbar-button
v-for="item in getGroupItems($options.groups.settings)"
:key="item.id"
:button="item"
@click="$emit('click', item)"
/>
- </gl-button-group>
+ </div>
</section>
</template>
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
index 38f586f0773..996ecea04e5 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
@@ -30,6 +30,15 @@ export default {
showButton() {
return Object.entries(this.button).length > 0;
},
+ showLabel() {
+ if (this.button.category === 'tertiary' && this.button.icon) {
+ return false;
+ }
+ return true;
+ },
+ isSelected() {
+ return this.button.category === 'tertiary' && this.button.selected;
+ },
},
mounted() {
if (this.button.data) {
@@ -55,11 +64,12 @@ export default {
:category="button.category"
:variant="button.variant"
type="button"
- :selected="button.selected"
+ :selected="isSelected"
:icon="icon"
:title="label"
:aria-label="label"
:class="button.class"
@click="clickHandler($event)"
- />
+ ><template v-if="showLabel">{{ label }}</template></gl-button
+ >
</template>
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 8ec83e4df1c..905126cae52 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -151,6 +151,7 @@ export class SourceEditorExtension {
instance.toolbar.updateItem(EXTENSION_SOFTWRAP_ID, {
selected: !isSoftWrapped,
});
+ document.querySelector('.soft-wrap-toggle')?.blur();
}
},
};
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
index 9ec1a97ba1a..60aa00da861 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -14,7 +14,6 @@ import {
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
EXTENSION_MARKDOWN_PREVIEW_LABEL,
EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
- EDITOR_TOOLBAR_BUTTON_GROUPS,
} from '../constants';
const fetchPreview = (text, previewMarkdownPath) => {
@@ -58,9 +57,6 @@ export class EditorMarkdownPreviewExtension {
this.toolbarButtons = [];
this.setupPreviewAction(instance);
- if (instance.toolbar) {
- this.setupToolbar(instance);
- }
const debouncedResizeHandler = debounce((entries) => {
for (const entry of entries) {
@@ -104,25 +100,6 @@ export class EditorMarkdownPreviewExtension {
instance.layout({ width, height });
}
- setupToolbar(instance) {
- this.toolbarButtons = [
- {
- id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- label: EXTENSION_MARKDOWN_PREVIEW_LABEL,
- icon: 'live-preview',
- selected: false,
- group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
- category: 'primary',
- selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
- onClick: () => instance.togglePreview(),
- data: {
- qaSelector: 'editor_toolbar_button',
- },
- },
- ];
- instance.toolbar.addItems(this.toolbarButtons);
- }
-
togglePreviewLayout(instance) {
const { width } = instance.getLayoutInfo();
let newWidth;
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index d240ad7353a..3a1188d7aab 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -359,7 +359,7 @@
"pattern": "\\.ya?ml$"
},
"rules": {
- "$ref": "#/definitions/rules"
+ "$ref": "#/definitions/includeRules"
},
"inputs": {
"$ref": "#/definitions/inputs"
@@ -399,6 +399,9 @@
}
]
},
+ "rules": {
+ "$ref": "#/definitions/includeRules"
+ },
"inputs": {
"$ref": "#/definitions/inputs"
}
@@ -418,6 +421,9 @@
"format": "uri-reference",
"pattern": "\\.ya?ml$"
},
+ "rules": {
+ "$ref": "#/definitions/includeRules"
+ },
"inputs": {
"$ref": "#/definitions/inputs"
}
@@ -435,6 +441,9 @@
"type": "string",
"format": "uri-reference"
},
+ "rules": {
+ "$ref": "#/definitions/includeRules"
+ },
"inputs": {
"$ref": "#/definitions/inputs"
}
@@ -453,6 +462,9 @@
"format": "uri-reference",
"pattern": "^https?://.+\\.ya?ml$"
},
+ "rules": {
+ "$ref": "#/definitions/includeRules"
+ },
"inputs": {
"$ref": "#/definitions/inputs"
}
@@ -794,6 +806,55 @@
]
}
},
+ "includeRules": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "markdownDescription": "You can use rules to conditionally include other configuration files. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#use-rules-with-include).",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "if": {
+ "$ref": "#/definitions/if"
+ },
+ "exists": {
+ "$ref": "#/definitions/exists"
+ },
+ "when": {
+ "markdownDescription": "Use `when: never` to exclude the configuration file if the condition matches. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#include-with-rulesif).",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "never",
+ "always"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "array",
+ "minLength": 1,
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
"workflowName": {
"type": "string",
"markdownDescription": "Defines the pipeline name. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowname).",
@@ -1067,11 +1128,7 @@
"type": "string",
"markdownDescription": "Determines the strategy for downloading and updating the cache. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachepolicy)",
"default": "pull-push",
- "enum": [
- "pull",
- "push",
- "pull-push"
- ]
+ "pattern": "pull-push|pull|push|\\$\\w{1,255}"
},
"unprotect": {
"type": "boolean",
diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js
index 69c81c35bd4..4566ab20258 100644
--- a/app/assets/javascripts/ensure_data.js
+++ b/app/assets/javascripts/ensure_data.js
@@ -1,4 +1,4 @@
-import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg';
+import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg?raw';
import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index b2843b79ba6..ce7a6f0abe7 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -8,7 +8,7 @@
* - Button Actions.
* [Mockup](https://gitlab.com/gitlab-org/gitlab-foss/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png)
*/
-import deployBoardSvg from '@gitlab/svgs/dist/illustrations/deploy-boards.svg';
+import deployBoardSvg from '@gitlab/svgs/dist/illustrations/deploy-boards.svg?raw';
import {
GlIcon,
GlLoadingIcon,
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index b63a6897a39..7905c5cf572 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -1,39 +1,121 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getEnvironment from '../graphql/queries/environment.query.graphql';
+import updateEnvironment from '../graphql/mutations/update_environment.mutation.graphql';
import EnvironmentForm from './environment_form.vue';
export default {
components: {
+ GlLoadingIcon,
EnvironmentForm,
},
- inject: ['projectEnvironmentsPath', 'updateEnvironmentPath'],
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectEnvironmentsPath', 'updateEnvironmentPath', 'projectPath'],
props: {
environment: {
required: true,
type: Object,
},
},
+ apollo: {
+ environment: {
+ query: getEnvironment,
+ variables() {
+ return {
+ environmentName: this.environment.name,
+ projectFullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ this.formEnvironment = data?.project?.environment || {};
+ },
+ },
+ },
data() {
return {
- formEnvironment: {
+ isQueryLoading: false,
+ loading: false,
+ formEnvironment: null,
+ };
+ },
+ mounted() {
+ if (this.glFeatures?.environmentSettingsToGraphql) {
+ this.fetchWithGraphql();
+ } else {
+ this.formEnvironment = {
id: this.environment.id,
name: this.environment.name,
externalUrl: this.environment.external_url,
- },
- loading: false,
- };
+ };
+ }
},
methods: {
+ async fetchWithGraphql() {
+ this.$apollo.addSmartQuery('environmentData', {
+ variables() {
+ return { environmentName: this.environment.name, projectFullPath: this.projectPath };
+ },
+ query: getEnvironment,
+ update(data) {
+ const result = data?.project?.environment || {};
+ this.formEnvironment = { ...result, clusterAgentId: result?.clusterAgent?.id };
+ },
+ watchLoading: (isLoading) => {
+ this.isQueryLoading = isLoading;
+ },
+ });
+ },
onChange(environment) {
this.formEnvironment = environment;
},
onSubmit() {
+ if (this.glFeatures?.environmentSettingsToGraphql) {
+ this.updateWithGraphql();
+ } else {
+ this.updateWithAxios();
+ }
+ },
+ async updateWithGraphql() {
+ this.loading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateEnvironment,
+ variables: {
+ input: {
+ id: this.formEnvironment.id,
+ externalUrl: this.formEnvironment.externalUrl,
+ clusterAgentId: this.formEnvironment.clusterAgentId,
+ },
+ },
+ });
+
+ const { errors } = data.environmentUpdate;
+
+ if (errors.length > 0) {
+ throw new Error(errors[0]?.message ?? errors[0]);
+ }
+
+ const { path } = data.environmentUpdate.environment;
+
+ if (path) {
+ visitUrl(path);
+ }
+ } catch (error) {
+ const { message } = error;
+ createAlert({ message });
+ } finally {
+ this.loading = false;
+ }
+ },
+ updateWithAxios() {
this.loading = true;
axios
.put(this.updateEnvironmentPath, {
- id: this.environment.id,
+ id: this.formEnvironment.id,
external_url: this.formEnvironment.externalUrl,
})
.then(({ data: { path } }) => visitUrl(path))
@@ -47,7 +129,9 @@ export default {
};
</script>
<template>
+ <gl-loading-icon v-if="isQueryLoading" class="gl-mt-5" />
<environment-form
+ v-else-if="formEnvironment"
:cancel-path="projectEnvironmentsPath"
:environment="formEnvironment"
:title="__('Edit environment')"
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 63169b790c7..6072d923b5f 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -4,14 +4,14 @@
* Used in the environments table.
*/
-import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
directives: {
GlModalDirective,
@@ -30,11 +30,15 @@ export default {
data() {
return {
isLoading: false,
+ item: {
+ text: s__('Environments|Delete environment'),
+ extraAttrs: {
+ variant: 'danger',
+ class: 'gl-text-red-500!',
+ },
+ },
};
},
- i18n: {
- title: s__('Environments|Delete environment'),
- },
mounted() {
if (!this.graphql) {
eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
@@ -65,12 +69,10 @@ export default {
};
</script>
<template>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
v-gl-modal-directive.delete-environment-modal
+ :item="item"
:loading="isLoading"
- variant="danger"
- @click="onClick"
- >
- {{ $options.i18n.title }}
- </gl-dropdown-item>
+ @action="onClick"
+ />
</template>
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 62ceb66d803..266b221b481 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -1,12 +1,22 @@
<script>
-import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlCollapsibleListbox,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { isAbsolute } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
ENVIRONMENT_NEW_HELP_TEXT,
ENVIRONMENT_EDIT_HELP_TEXT,
} from 'ee_else_ce/environments/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getUserAuthorizedAgents from '../graphql/queries/user_authorized_agents.query.graphql';
export default {
components: {
@@ -14,10 +24,15 @@ export default {
GlForm,
GlFormGroup,
GlFormInput,
+ GlCollapsibleListbox,
GlLink,
GlSprintf,
},
- inject: { protectedEnvironmentSettingsPath: { default: '' } },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ protectedEnvironmentSettingsPath: { default: '' },
+ projectPath: { default: '' },
+ },
props: {
environment: {
required: true,
@@ -47,8 +62,11 @@ export default {
nameDisabledLinkText: __('How do I rename an environment?'),
urlLabel: __('External URL'),
urlFeedback: __('The URL should start with http:// or https://'),
+ agentLabel: s__('Environments|GitLab agent'),
+ agentHelpText: s__('Environments|Select agent'),
save: __('Save'),
cancel: __('Cancel'),
+ reset: __('Reset'),
},
helpPagePath: helpPagePath('ci/environments/index.md'),
renamingDisabledHelpPagePath: helpPagePath('ci/environments/index.md', {
@@ -60,6 +78,10 @@ export default {
name: null,
url: null,
},
+ userAccessAuthorizedAgents: [],
+ loadingAgentsList: false,
+ selectedAgentId: this.environment.clusterAgentId,
+ searchTerm: '',
};
},
computed: {
@@ -75,6 +97,37 @@ export default {
url: this.visited.url && isAbsolute(this.environment.externalUrl),
};
},
+ agentsList() {
+ return this.userAccessAuthorizedAgents.map((node) => {
+ return {
+ value: node?.agent?.id,
+ text: node?.agent?.name,
+ };
+ });
+ },
+ dropdownToggleText() {
+ if (!this.selectedAgentId) {
+ return this.$options.i18n.agentHelpText;
+ }
+ const selectedAgentById = this.agentsList.find(
+ (agent) => agent.value === this.selectedAgentId,
+ );
+ return selectedAgentById?.text || this.environment.clusterAgent?.name;
+ },
+ filteredAgentsList() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.agentsList.filter((item) =>
+ item.text.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ showAgentsSelect() {
+ return this.glFeatures?.environmentSettingsToGraphql;
+ },
+ },
+ watch: {
+ environment(change) {
+ this.selectedAgentId = change.clusterAgentId;
+ },
},
methods: {
onChange(env) {
@@ -83,6 +136,23 @@ export default {
visit(field) {
this.visited[field] = true;
},
+ getAgentsList() {
+ this.$apollo.addSmartQuery('userAccessAuthorizedAgents', {
+ variables() {
+ return { projectFullPath: this.projectPath };
+ },
+ query: getUserAuthorizedAgents,
+ update: (data) => {
+ return data?.project?.userAccessAuthorizedAgents?.nodes || [];
+ },
+ watchLoading: (isLoading) => {
+ this.loadingAgentsList = isLoading;
+ },
+ });
+ },
+ onAgentSearch(search) {
+ this.searchTerm = search;
+ },
},
};
</script>
@@ -153,6 +223,29 @@ export default {
/>
</gl-form-group>
+ <gl-form-group
+ v-if="showAgentsSelect"
+ :label="$options.i18n.agentLabel"
+ label-for="environment_agent"
+ >
+ <gl-collapsible-listbox
+ id="environment_agent"
+ v-model="selectedAgentId"
+ class="gl-w-full"
+ block
+ :items="filteredAgentsList"
+ :loading="loadingAgentsList"
+ :toggle-text="dropdownToggleText"
+ :header-text="$options.i18n.agentHelpText"
+ :reset-button-label="$options.i18n.reset"
+ :searchable="true"
+ @shown="getAgentsList"
+ @search="onAgentSearch"
+ @select="onChange({ ...environment, clusterAgentId: $event })"
+ @reset="onChange({ ...environment, clusterAgentId: null })"
+ />
+ </gl-form-group>
+
<div class="gl-mr-6">
<gl-button
:loading="loading"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 1486a66fe13..b02142c24cf 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -16,12 +16,10 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import DeleteComponent from './environment_delete.vue';
import ExternalUrlComponent from './environment_external_url.vue';
-import MonitoringButtonComponent from './environment_monitoring.vue';
import PinComponent from './environment_pin.vue';
import RollbackComponent from './environment_rollback.vue';
import StopComponent from './environment_stop.vue';
@@ -43,7 +41,6 @@ export default {
GlIcon,
GlLink,
GlSprintf,
- MonitoringButtonComponent,
PinComponent,
DeleteComponent,
RollbackComponent,
@@ -57,7 +54,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin, glFeatureFlagsMixin()],
+ mixins: [timeagoMixin],
props: {
model: {
@@ -529,14 +526,6 @@ export default {
return this.model.environment_path || '';
},
- monitoringUrl() {
- return this.model.metrics_path || '';
- },
-
- canShowMetricsLink() {
- return Boolean(!this.glFeatures.removeMonitorMetrics && this.monitoringUrl);
- },
-
terminalPath() {
return this.model?.terminal_path ?? '';
},
@@ -549,7 +538,6 @@ export default {
return (
this.actions.length > 0 ||
this.externalURL ||
- this.canShowMetricsLink ||
this.canStopEnvironment ||
this.canDeleteEnvironment ||
this.canRetry
@@ -571,11 +559,7 @@ export default {
},
hasExtraActions() {
return Boolean(
- this.canRetry ||
- this.canShowAutoStopDate ||
- this.canShowMetricsLink ||
- this.terminalPath ||
- this.canDeleteEnvironment,
+ this.canRetry || this.canShowAutoStopDate || this.terminalPath || this.canDeleteEnvironment,
);
},
},
@@ -860,14 +844,6 @@ export default {
data-track-label="environment_pin"
/>
- <monitoring-button-component
- v-if="canShowMetricsLink"
- :monitoring-url="monitoringUrl"
- data-track-action="click_button"
- data-track-label="environment_monitoring"
- data-testid="environment-monitoring"
- />
-
<terminal-button-component
v-if="terminalPath"
:terminal-path="terminalPath"
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
deleted file mode 100644
index 06c7f10223a..00000000000
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<script>
-import { GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-/**
- * Renders the Monitoring (Metrics) link in environments table.
- */
-export default {
- components: {
- GlDropdownItem,
- },
- props: {
- monitoringUrl: {
- type: String,
- required: true,
- },
- },
- title: __('Monitoring'),
-};
-</script>
-<template>
- <gl-dropdown-item :href="monitoringUrl" rel="noopener noreferrer nofollow" target="_blank">
- {{ $options.title }}
- </gl-dropdown-item>
-</template>
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index f5a83b97552..1a63bfa2c1c 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -3,14 +3,14 @@
* Renders a prevent auto-stop button.
* Used in environments table.
*/
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
autoStopUrl: {
@@ -23,6 +23,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ item: { text: __('Prevent auto-stopping') },
+ };
+ },
methods: {
onPinClick() {
if (this.graphql) {
@@ -35,11 +40,8 @@ export default {
}
},
},
- title: __('Prevent auto-stopping'),
};
</script>
<template>
- <gl-dropdown-item @click="onPinClick">
- {{ $options.title }}
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item :item="item" @action="onPinClick" />
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index f7a853f3128..291d8558a74 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,14 +5,14 @@
*
* Makes a post request when the button is clicked.
*/
-import { GlModalDirective, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
directives: {
GlModal: GlModalDirective,
@@ -41,12 +41,14 @@ export default {
},
},
- computed: {
- title() {
- return this.isLastDeployment
- ? s__('Environments|Re-deploy to environment')
- : s__('Environments|Rollback environment');
- },
+ data() {
+ return {
+ item: {
+ text: this.isLastDeployment
+ ? s__('Environments|Re-deploy to environment')
+ : s__('Environments|Rollback environment'),
+ },
+ };
},
methods: {
@@ -71,7 +73,5 @@ export default {
};
</script>
<template>
- <gl-dropdown-item v-gl-modal.confirm-rollback-modal @click="onClick">
- {{ title }}
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item v-gl-modal.confirm-rollback-modal :item="item" @action="onClick" />
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 0df07f0457f..1c4209397b1 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -3,12 +3,12 @@
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
terminalPath: {
@@ -22,11 +22,13 @@ export default {
default: false,
},
},
- title: __('Terminal'),
+ data() {
+ return {
+ item: { text: __('Terminal'), href: this.terminalPath },
+ };
+ },
};
</script>
<template>
- <gl-dropdown-item :href="terminalPath" :disabled="disabled">
- {{ $options.title }}
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item :item="item" :disabled="disabled" />
</template>
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index 0507abf3eaf..92960e2835e 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -4,7 +4,6 @@ import csrf from '~/lib/utils/csrf';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
import DeployFreezeAlert from './deploy_freeze_alert.vue';
@@ -24,7 +23,7 @@ export default {
GlModalDirective,
GlTooltip,
},
- mixins: [timeagoMixin, glFeatureFlagsMixin()],
+ mixins: [timeagoMixin],
props: {
environment: {
type: Object,
@@ -51,11 +50,6 @@ export default {
required: false,
default: '',
},
- metricsPath: {
- type: String,
- required: false,
- default: '',
- },
updatePath: {
type: String,
required: false,
@@ -69,8 +63,6 @@ export default {
},
i18n: {
autoStopAtText: s__('Environments|Auto stops %{autoStopAt}'),
- metricsButtonTitle: __('See metrics'),
- metricsButtonText: __('Monitoring'),
editButtonText: __('Edit'),
stopButtonText: s__('Environments|Stop'),
deleteButtonText: s__('Environments|Delete'),
@@ -91,9 +83,6 @@ export default {
shouldShowTerminalButton() {
return this.canAdminEnvironment && this.environment.hasTerminals;
},
- shouldShowMetricsButton() {
- return Boolean(!this.glFeatures.removeMonitorMetrics && this.shouldShowExternalUrlButton);
- },
},
};
</script>
@@ -146,17 +135,6 @@ export default {
target="_blank"
>{{ $options.i18n.externalButtonText }}</gl-button
>
- <gl-button
- v-if="shouldShowMetricsButton"
- v-gl-tooltip.hover
- data-testid="metrics-button"
- :href="metricsPath"
- :title="$options.i18n.metricsButtonTitle"
- icon="chart"
- class="gl-mr-2"
- >
- {{ $options.i18n.metricsButtonText }}
- </gl-button>
<gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath">
{{ $options.i18n.editButtonText }}
</gl-button>
diff --git a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
index 7660912f93a..03bde8d64ac 100644
--- a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
@@ -1,68 +1,37 @@
<script>
-import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util';
import { AGENT_STATUSES } from '~/clusters_list/constants';
import { s__ } from '~/locale';
-import getK8sClusterAgentQuery from '../graphql/queries/k8s_cluster_agent.query.graphql';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
- GlLoadingIcon,
TimeAgoTooltip,
- GlAlert,
},
props: {
- agentName: {
- required: true,
- type: String,
- },
- agentId: {
- required: true,
- type: String,
- },
- agentProjectPath: {
- required: true,
- type: String,
- },
- },
- apollo: {
clusterAgent: {
- query: getK8sClusterAgentQuery,
- variables() {
- return {
- agentName: this.agentName,
- projectPath: this.agentProjectPath,
- };
- },
- update: (data) => data?.project?.clusterAgent,
- error() {
- this.clusterAgent = null;
- },
+ required: true,
+ type: Object,
},
},
- data() {
- return {
- clusterAgent: null,
- };
- },
computed: {
- isLoading() {
- return this.$apollo.queries.clusterAgent.loading;
- },
agentLastContact() {
return getAgentLastContact(this.clusterAgent.tokens.nodes);
},
agentStatus() {
return getAgentStatus(this.agentLastContact);
},
+ agentId() {
+ return getIdFromGraphQLId(this.clusterAgent.id);
+ },
},
methods: {},
i18n: {
- loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
agentId: s__('ClusterAgents|Agent ID #%{agentId}'),
neverConnectedText: s__('ClusterAgents|Never'),
},
@@ -70,8 +39,7 @@ export default {
};
</script>
<template>
- <gl-loading-icon v-if="isLoading" inline />
- <div v-else-if="clusterAgent" class="gl-text-gray-900">
+ <div class="gl-text-gray-900">
<gl-icon name="kubernetes-agent" class="gl-text-gray-500" />
<gl-link :href="clusterAgent.webPath" class="gl-mr-3">
<gl-sprintf :message="$options.i18n.agentId"
@@ -92,8 +60,4 @@ export default {
<span v-else>{{ $options.i18n.neverConnectedText }}</span>
</span>
</div>
-
- <gl-alert v-else variant="danger" :dismissible="false">
- {{ $options.i18n.loadingError }}
- </gl-alert>
</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
index 1f15c4daa2f..a1efeaac359 100644
--- a/app/assets/javascripts/environments/components/kubernetes_overview.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -2,10 +2,11 @@
import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import csrf from '~/lib/utils/csrf';
-import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import KubernetesAgentInfo from './kubernetes_agent_info.vue';
import KubernetesPods from './kubernetes_pods.vue';
import KubernetesTabs from './kubernetes_tabs.vue';
+import KubernetesStatusBar from './kubernetes_status_bar.vue';
export default {
components: {
@@ -15,20 +16,13 @@ export default {
KubernetesAgentInfo,
KubernetesPods,
KubernetesTabs,
+ KubernetesStatusBar,
},
inject: ['kasTunnelUrl'],
props: {
- agentName: {
+ clusterAgent: {
required: true,
- type: String,
- },
- agentId: {
- required: true,
- type: String,
- },
- agentProjectPath: {
- required: true,
- type: String,
+ type: Object,
},
namespace: {
required: false,
@@ -40,6 +34,9 @@ export default {
return {
isVisible: false,
error: '',
+ hasFailedState: false,
+ podsLoading: false,
+ workloadTypesLoading: false,
};
},
computed: {
@@ -50,17 +47,25 @@ export default {
return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand;
},
gitlabAgentId() {
- const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId;
- return id.toString();
+ return getIdFromGraphQLId(this.clusterAgent.id).toString();
},
k8sAccessConfiguration() {
return {
basePath: this.kasTunnelUrl,
baseOptions: {
headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers },
+ withCredentials: true,
},
};
},
+ clusterHealthStatus() {
+ const clusterDataLoading = this.podsLoading || this.workloadTypesLoading;
+ if (clusterDataLoading) {
+ return '';
+ }
+
+ return this.hasFailedState ? 'error' : 'success';
+ },
},
methods: {
toggleCollapse() {
@@ -91,11 +96,8 @@ export default {
</p>
<gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4">
<template v-if="isVisible">
- <kubernetes-agent-info
- :agent-name="agentName"
- :agent-id="agentId"
- :agent-project-path="agentProjectPath"
- class="gl-mb-5" />
+ <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" class="gl-mb-4" />
+ <kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-5" />
<gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
{{ error }}
@@ -105,12 +107,16 @@ export default {
:configuration="k8sAccessConfiguration"
:namespace="namespace"
class="gl-mb-5"
- @cluster-error="onClusterError" />
+ @cluster-error="onClusterError"
+ @loading="podsLoading = $event"
+ @failed="hasFailedState = true" />
<kubernetes-tabs
:configuration="k8sAccessConfiguration"
:namespace="namespace"
class="gl-mb-5"
@cluster-error="onClusterError"
+ @loading="workloadTypesLoading = $event"
+ @failed="hasFailedState = true"
/></template>
</gl-collapse>
</div>
diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue
index a153331ee58..aded3a4d0c4 100644
--- a/app/assets/javascripts/environments/components/kubernetes_pods.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue
@@ -3,6 +3,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { s__ } from '~/locale';
import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql';
+import { PHASE_RUNNING, PHASE_PENDING, PHASE_SUCCEEDED, PHASE_FAILED } from '../constants';
export default {
components: {
@@ -25,6 +26,9 @@ export default {
this.error = error;
this.$emit('cluster-error', this.error);
},
+ watchLoading(isLoading) {
+ this.$emit('loading', isLoading);
+ },
},
},
props: {
@@ -42,41 +46,39 @@ export default {
error: '',
};
},
-
computed: {
podStats() {
if (!this.k8sPods) return null;
return [
{
- // eslint-disable-next-line @gitlab/require-i18n-strings
- value: this.getPodsByPhase('Running'),
+ value: this.countPodsByPhase(PHASE_RUNNING),
title: this.$options.i18n.runningPods,
},
{
- // eslint-disable-next-line @gitlab/require-i18n-strings
- value: this.getPodsByPhase('Pending'),
+ value: this.countPodsByPhase(PHASE_PENDING),
title: this.$options.i18n.pendingPods,
},
{
- // eslint-disable-next-line @gitlab/require-i18n-strings
- value: this.getPodsByPhase('Succeeded'),
+ value: this.countPodsByPhase(PHASE_SUCCEEDED),
title: this.$options.i18n.succeededPods,
},
{
- // eslint-disable-next-line @gitlab/require-i18n-strings
- value: this.getPodsByPhase('Failed'),
+ value: this.countPodsByPhase(PHASE_FAILED),
title: this.$options.i18n.failedPods,
},
];
},
loading() {
- return this.$apollo.queries.k8sPods.loading;
+ return this.$apollo?.queries?.k8sPods?.loading;
},
},
methods: {
- getPodsByPhase(phase) {
+ countPodsByPhase(phase) {
const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase);
+ if (phase === PHASE_FAILED && filteredPods.length) {
+ this.$emit('failed');
+ }
return filteredPods.length;
},
},
diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
new file mode 100644
index 00000000000..94cd7438e46
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { HEALTH_BADGES } from '../constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlBadge,
+ },
+ props: {
+ clusterHealthStatus: {
+ required: false,
+ type: String,
+ default: '',
+ validator(val) {
+ return ['error', 'success', ''].includes(val);
+ },
+ },
+ },
+ computed: {
+ healthBadge() {
+ return HEALTH_BADGES[this.clusterHealthStatus];
+ },
+ },
+ i18n: {
+ healthLabel: s__('Environment|Environment health'),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-mb-2">
+ <span class="gl-font-sm gl-font-monospace gl-mr-3">{{ $options.i18n.healthLabel }}</span>
+ <gl-loading-icon v-if="!clusterHealthStatus" size="sm" inline />
+ <gl-badge v-else-if="healthBadge" :variant="healthBadge.variant">
+ {{ healthBadge.text }}
+ </gl-badge>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue
index 85fc1c1a07d..b00e82809f6 100644
--- a/app/assets/javascripts/environments/components/kubernetes_summary.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue
@@ -32,6 +32,12 @@ export default {
error(error) {
this.$emit('cluster-error', error);
},
+ result() {
+ this.checkFailed();
+ },
+ watchLoading(isLoading) {
+ this.$emit('loading', isLoading);
+ },
},
},
props: {
@@ -46,7 +52,7 @@ export default {
},
computed: {
summaryLoading() {
- return this.$apollo.queries.k8sWorkloads.loading;
+ return this.$apollo?.queries?.k8sWorkloads?.loading;
},
summaryCount() {
return this.k8sWorkloads ? Object.values(this.k8sWorkloads).flat().length : 0;
@@ -128,6 +134,17 @@ export default {
};
},
},
+ methods: {
+ checkFailed() {
+ const failed = this.summaryObjects.some((workloadType) => {
+ return workloadType.items?.failed?.length > 0;
+ });
+
+ if (failed) {
+ this.$emit('failed');
+ }
+ },
+ },
i18n: {
summaryTitle: s__('Environment|Summary'),
deployments: s__('Environment|Deployments'),
diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
index b900c23b2b7..4492d209e3b 100644
--- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
@@ -134,7 +134,12 @@ export default {
</script>
<template>
<gl-tabs>
- <kubernetes-summary :namespace="namespace" :configuration="configuration" />
+ <kubernetes-summary
+ :namespace="namespace"
+ :configuration="configuration"
+ @loading="$emit('loading', $event)"
+ @failed="$emit('failed')"
+ />
<gl-tab>
<template #title>
diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue
index 4b58d133817..3e5f4070066 100644
--- a/app/assets/javascripts/environments/components/new_environment.vue
+++ b/app/assets/javascripts/environments/components/new_environment.vue
@@ -2,13 +2,16 @@
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import createEnvironment from '../graphql/mutations/create_environment.mutation.graphql';
import EnvironmentForm from './environment_form.vue';
export default {
components: {
EnvironmentForm,
},
- inject: ['projectEnvironmentsPath'],
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectEnvironmentsPath', 'projectPath'],
data() {
return {
environment: {
@@ -23,6 +26,46 @@ export default {
this.environment = env;
},
onSubmit() {
+ if (this.glFeatures?.environmentSettingsToGraphql) {
+ this.createWithGraphql();
+ } else {
+ this.createWithAxios();
+ }
+ },
+ async createWithGraphql() {
+ this.loading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: createEnvironment,
+ variables: {
+ input: {
+ name: this.environment.name,
+ externalUrl: this.environment.externalUrl,
+ projectPath: this.projectPath,
+ clusterAgentId: this.environment.clusterAgentId,
+ },
+ },
+ });
+
+ const { errors } = data.environmentCreate;
+
+ if (errors.length > 0) {
+ throw new Error(errors[0]?.message ?? errors[0]);
+ }
+
+ const { path } = data.environmentCreate.environment;
+
+ if (path) {
+ visitUrl(path);
+ }
+ } catch (error) {
+ const { message } = error;
+ createAlert({ message });
+ } finally {
+ this.loading = false;
+ }
+ },
+ createWithAxios() {
this.loading = true;
axios
.post(this.projectEnvironmentsPath, {
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 912c558c3ce..1f3d429cc3e 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -1,9 +1,9 @@
<script>
import {
- GlCollapse,
- GlDropdown,
GlBadge,
GlButton,
+ GlCollapse,
+ GlDisclosureDropdown,
GlLink,
GlSprintf,
GlTooltipDirective as GlTooltip,
@@ -13,12 +13,12 @@ import { truncate } from '~/lib/utils/text_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
+import getEnvironmentClusterAgent from '../graphql/queries/environment_cluster_agent.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
import StopComponent from './environment_stop.vue';
import Rollback from './environment_rollback.vue';
import Pin from './environment_pin.vue';
-import Monitoring from './environment_monitoring.vue';
import Terminal from './environment_terminal_button.vue';
import Delete from './environment_delete.vue';
import Deployment from './deployment.vue';
@@ -27,8 +27,8 @@ import KubernetesOverview from './kubernetes_overview.vue';
export default {
components: {
+ GlDisclosureDropdown,
GlCollapse,
- GlDropdown,
GlBadge,
GlButton,
GlLink,
@@ -39,7 +39,6 @@ export default {
ExternalUrl,
StopComponent,
Rollback,
- Monitoring,
Pin,
Terminal,
TimeAgoTooltip,
@@ -53,7 +52,7 @@ export default {
GlTooltip,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['helpPagePath'],
+ inject: ['helpPagePath', 'projectPath'],
props: {
environment: {
required: true,
@@ -83,7 +82,7 @@ export default {
tierTooltip: s__('Environment|Deployment tier'),
},
data() {
- return { visible: false };
+ return { visible: false, clusterAgent: null };
},
computed: {
icon() {
@@ -133,7 +132,6 @@ export default {
return Boolean(
this.retryPath ||
this.canShowAutoStopDate ||
- this.canShowMetricsLink ||
this.terminalPath ||
this.canDeleteEnvironment,
);
@@ -154,12 +152,6 @@ export default {
autoStopPath() {
return this.environment?.cancelAutoStopPath ?? '';
},
- metricsPath() {
- return this.environment?.metricsPath ?? '';
- },
- canShowMetricsLink() {
- return Boolean(!this.glFeatures.removeMonitorMetrics && this.metricsPath);
- },
terminalPath() {
return this.environment?.terminalPath ?? '';
},
@@ -172,23 +164,33 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
- agent() {
- return this.environment?.agent || {};
- },
isKubernetesOverviewAvailable() {
return this.glFeatures?.kasUserAccessProject;
},
- hasRequiredAgentData() {
- const { project, id, name } = this.agent || {};
- return project && id && name;
- },
showKubernetesOverview() {
- return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
+ return Boolean(this.isKubernetesOverviewAvailable && this.clusterAgent);
},
},
methods: {
- toggleCollapse() {
+ toggleEnvironmentCollapse() {
this.visible = !this.visible;
+
+ if (this.visible) {
+ this.getClusterAgent();
+ }
+ },
+ getClusterAgent() {
+ if (!this.isKubernetesOverviewAvailable || this.clusterAgent) return;
+
+ this.$apollo.addSmartQuery('environmentClusterAgent', {
+ variables() {
+ return { environmentName: this.environment.name, projectFullPath: this.projectPath };
+ },
+ query: getEnvironmentClusterAgent,
+ update(data) {
+ this.clusterAgent = data?.project?.environment?.clusterAgent;
+ },
+ });
},
},
deploymentClasses: [
@@ -231,7 +233,7 @@ export default {
:aria-label="label"
size="small"
category="secondary"
- @click="toggleCollapse"
+ @click="toggleEnvironmentCollapse"
/>
<gl-link
v-gl-tooltip
@@ -282,14 +284,14 @@ export default {
graphql
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="hasExtraActions"
- icon="ellipsis_v"
text-sr-only
- :text="__('More actions')"
- category="secondary"
no-caret
- right
+ icon="ellipsis_v"
+ category="secondary"
+ placement="right"
+ :toggle-text="__('More actions')"
>
<rollback
v-if="retryPath"
@@ -309,14 +311,6 @@ export default {
data-track-label="environment_pin"
/>
- <monitoring
- v-if="canShowMetricsLink"
- :monitoring-url="metricsPath"
- data-track-action="click_button"
- data-track-label="environment_monitoring"
- data-testid="environment-monitoring"
- />
-
<terminal
v-if="terminalPath"
:terminal-path="terminalPath"
@@ -331,7 +325,7 @@ export default {
data-track-label="environment_delete"
graphql
/>
- </gl-dropdown>
+ </gl-disclosure-dropdown>
</div>
</div>
</div>
@@ -376,10 +370,8 @@ export default {
</div>
<div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses">
<kubernetes-overview
- :agent-project-path="agent.project"
- :agent-name="agent.name"
- :agent-id="agent.id"
- :namespace="agent.kubernetesNamespace"
+ :cluster-agent="clusterAgent"
+ :namespace="environment.kubernetesNamespace"
/>
</div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 448cee530f6..2b178964c37 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -89,3 +89,22 @@ export const ENVIRONMENT_NEW_HELP_TEXT = __(
export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT;
export const SERVICES_LIMIT_PER_PAGE = 10;
+
+export const CLUSTER_STATUS_HEALTHY_TEXT = s__('Environment|Healthy');
+export const CLUSTER_STATUS_UNHEALTHY_TEXT = s__('Environment|Unhealthy');
+
+export const HEALTH_BADGES = {
+ success: {
+ variant: 'success',
+ text: CLUSTER_STATUS_HEALTHY_TEXT,
+ },
+ error: {
+ variant: 'danger',
+ text: CLUSTER_STATUS_UNHEALTHY_TEXT,
+ },
+};
+
+export const PHASE_RUNNING = 'Running';
+export const PHASE_PENDING = 'Pending';
+export const PHASE_SUCCEEDED = 'Succeeded';
+export const PHASE_FAILED = 'Failed';
diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js
index a128d2fb3c7..b26d96e15bd 100644
--- a/app/assets/javascripts/environments/edit.js
+++ b/app/assets/javascripts/environments/edit.js
@@ -1,19 +1,38 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import EditEnvironment from './components/edit_environment.vue';
+import { apolloProvider } from './graphql/client';
-export default (el) =>
- new Vue({
+Vue.use(VueApollo);
+
+export default (el) => {
+ if (!el) {
+ return null;
+ }
+
+ const {
+ projectEnvironmentsPath,
+ updateEnvironmentPath,
+ protectedEnvironmentSettingsPath,
+ projectPath,
+ environment,
+ } = el.dataset;
+
+ return new Vue({
el,
+ apolloProvider: apolloProvider(),
provide: {
- projectEnvironmentsPath: el.dataset.projectEnvironmentsPath,
- updateEnvironmentPath: el.dataset.updateEnvironmentPath,
- protectedEnvironmentSettingsPath: el.dataset.protectedEnvironmentSettingsPath,
+ projectEnvironmentsPath,
+ updateEnvironmentPath,
+ protectedEnvironmentSettingsPath,
+ projectPath,
},
render(h) {
return h(EditEnvironment, {
props: {
- environment: JSON.parse(el.dataset.environment),
+ environment: JSON.parse(environment),
},
});
},
});
+};
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
index 92a0b0e550e..787302df60f 100644
--- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -110,6 +110,7 @@ export default {
data-testid="rollback-button"
:title="rollbackButtonTitle"
:icon="rollbackIcon"
+ :aria-label="rollbackButtonTitle"
@click="onRollbackClick"
/>
<environment-approval
diff --git a/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql
new file mode 100644
index 00000000000..99330ecca80
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/create_environment.mutation.graphql
@@ -0,0 +1,9 @@
+mutation createEnvironment($input: EnvironmentCreateInput!) {
+ environmentCreate(input: $input) {
+ environment {
+ id
+ path
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql
new file mode 100644
index 00000000000..9ea0e3609cb
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/mutations/update_environment.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateEnvironment($input: EnvironmentUpdateInput!) {
+ environmentUpdate(input: $input) {
+ environment {
+ id
+ path
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql
new file mode 100644
index 00000000000..20402e8d32e
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment.query.graphql
@@ -0,0 +1,14 @@
+query getEnvironment($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ name
+ externalUrl
+ clusterAgent {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql
new file mode 100644
index 00000000000..760f1fba897
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/environment_cluster_agent.query.graphql
@@ -0,0 +1,19 @@
+query getEnvironmentClusterAgent($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ clusterAgent {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ id
+ lastUsedAt
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
deleted file mode 100644
index bd45d2dba2f..00000000000
--- a/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
+++ /dev/null
@@ -1,15 +0,0 @@
-query getK8sClusterAgentQuery($projectPath: ID!, $agentName: String!) {
- project(fullPath: $projectPath) {
- id
- clusterAgent(name: $agentName) {
- id
- webPath
- tokens {
- nodes {
- id
- lastUsedAt
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql b/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql
new file mode 100644
index 00000000000..bba85888543
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/user_authorized_agents.query.graphql
@@ -0,0 +1,13 @@
+query getUserAuthorizedAgents($projectFullPath: ID!) {
+ project(fullPath: $projectFullPath) {
+ id
+ userAccessAuthorizedAgents {
+ nodes {
+ agent {
+ id
+ name
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index f73cb7fe1bc..fb9a7a02d07 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -51,7 +51,6 @@ export const initHeader = () => {
canAdminEnvironment: dataset.canAdminEnvironment,
cancelAutoStopPath: dataset.environmentCancelAutoStopPath,
terminalPath: dataset.environmentTerminalPath,
- metricsPath: dataset.environmentMetricsPath,
updatePath: dataset.environmentEditPath,
},
});
diff --git a/app/assets/javascripts/environments/new.js b/app/assets/javascripts/environments/new.js
index 76aaf809d17..5dd112ac5e6 100644
--- a/app/assets/javascripts/environments/new.js
+++ b/app/assets/javascripts/environments/new.js
@@ -1,11 +1,23 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import NewEnvironment from './components/new_environment.vue';
+import { apolloProvider } from './graphql/client';
-export default (el) =>
- new Vue({
+Vue.use(VueApollo);
+
+export default (el) => {
+ if (!el) {
+ return null;
+ }
+
+ const { projectEnvironmentsPath, projectPath } = el.dataset;
+
+ return new Vue({
el,
- provide: { projectEnvironmentsPath: el.dataset.projectEnvironmentsPath },
+ apolloProvider: apolloProvider(),
+ provide: { projectEnvironmentsPath, projectPath },
render(h) {
return h(NewEnvironment);
},
});
+};
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index ccadf940fe3..0151dbb0bf7 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -13,7 +13,6 @@ import {
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_WARNING } from '~/alert';
import { __, sprintf, n__ } from '~/locale';
-import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import query from '../queries/details.query.graphql';
@@ -25,6 +24,7 @@ import {
import { severityLevel, severityLevelVariant, errorStatus } from '../constants';
import Stacktrace from './stacktrace.vue';
import ErrorDetailsInfo from './error_details_info.vue';
+import TimelineChart from './timeline_chart.vue';
const SENTRY_TIMEOUT = 10000;
@@ -43,6 +43,7 @@ export default {
GlDropdownDivider,
TimeAgoTooltip,
ErrorDetailsInfo,
+ TimelineChart,
},
props: {
issueUpdatePath: {
@@ -69,6 +70,10 @@ export default {
type: String,
required: true,
},
+ integratedErrorTrackingEnabled: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
error: {
@@ -188,8 +193,7 @@ export default {
]),
createIssue() {
this.issueCreationInProgress = true;
- const { category, action } = trackCreateIssueFromError;
- Tracking.event(category, action);
+ trackCreateIssueFromError(this.integratedErrorTrackingEnabled);
this.$refs.sentryIssueForm.submit();
},
onIgnoreStatusUpdate() {
@@ -224,12 +228,10 @@ export default {
}
},
trackPageViews() {
- const { category, action } = trackErrorDetailsViewsOptions;
- Tracking.event(category, action);
+ trackErrorDetailsViewsOptions(this.integratedErrorTrackingEnabled);
},
trackStatusUpdate(status) {
- const { category, action } = trackErrorStatusUpdateOptions(status);
- Tracking.event(category, action);
+ trackErrorStatusUpdateOptions(status, this.integratedErrorTrackingEnabled);
},
},
};
@@ -237,7 +239,7 @@ export default {
<template>
<div>
- <div v-if="errorLoading" class="py-3">
+ <div v-if="errorLoading" class="gl-py-5">
<gl-loading-icon size="lg" />
</div>
@@ -258,23 +260,25 @@ export default {
{{ __('No stack trace for this error') }}
</gl-alert>
- <div class="error-details-header d-flex py-2 justify-content-between">
+ <div
+ class="error-details-header gl-border-b gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-py-3 gl-justify-content-space-between"
+ >
<div
v-if="!loadingStacktrace && stacktrace"
- class="error-details-meta my-auto"
+ class="gl-my-auto gl-text-truncate"
data-qa-selector="reported_text"
>
<gl-sprintf :message="__('Reported %{timeAgo} by %{reportedBy}')">
<template #reportedBy>
- <strong class="error-details-meta-culprit">{{ error.culprit }}</strong>
+ <strong>{{ error.culprit }}</strong>
</template>
<template #timeAgo>
<time-ago-tooltip :time="stacktraceData.date_received" />
</template>
</gl-sprintf>
</div>
- <div class="error-details-actions">
- <div class="d-inline-flex bv-d-sm-down-none">
+ <div>
+ <div class="gl-display-none gl-md-display-inline-flex">
<gl-button
:loading="updatingIgnoreStatus"
data-testid="update-ignore-status-btn"
@@ -283,7 +287,7 @@ export default {
{{ ignoreBtnLabel }}
</gl-button>
<gl-button
- class="ml-2"
+ class="gl-ml-3"
category="secondary"
variant="confirm"
:loading="updatingResolveStatus"
@@ -294,7 +298,7 @@ export default {
</gl-button>
<gl-button
v-if="error.gitlabIssuePath"
- class="ml-2"
+ class="gl-ml-3"
data-testid="view_issue_button"
:href="error.gitlabIssuePath"
variant="confirm"
@@ -305,7 +309,7 @@ export default {
ref="sentryIssueForm"
:action="projectIssuesPath"
method="POST"
- class="d-inline-block ml-2"
+ class="gl-display-inline-block gl-ml-3"
>
<gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
<input name="issue[description]" :value="issueDescription" type="hidden" />
@@ -329,7 +333,7 @@ export default {
</div>
<gl-dropdown
text="Options"
- class="error-details-options d-md-none"
+ class="gl-w-full gl-md-display-none"
right
:disabled="issueUpdateInProgress"
>
@@ -362,7 +366,7 @@ export default {
</div>
<div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
- <h2 class="text-truncate">{{ error.title }}</h2>
+ <h2 class="gl-text-truncate">{{ error.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
<gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="gl-mr-3">
@@ -373,12 +377,17 @@ export default {
<error-details-info :error="error" />
- <div v-if="loadingStacktrace" class="py-3">
+ <div v-if="error.frequency" class="gl-mt-8">
+ <h3>{{ __('Last 24 hours') }}</h3>
+ <timeline-chart :timeline-data="error.frequency" :height="200" />
+ </div>
+
+ <div v-if="loadingStacktrace" class="gl-py-5">
<gl-loading-icon size="lg" />
</div>
<template v-else-if="showStacktrace">
- <h3 class="my-4">{{ __('Stack trace') }}</h3>
+ <h3 class="gl-my-6">{{ __('Stack trace') }}</h3>
<stacktrace :entries="stacktrace" />
</template>
</div>
diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue
index f6f39f178fb..0b4eabe25d1 100644
--- a/app/assets/javascripts/error_tracking/components/error_details_info.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue
@@ -34,20 +34,14 @@ export default {
lastReleaseLink() {
return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
},
- firstCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
- },
- lastCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
- },
shortFirstReleaseVersion() {
- return this.error.firstReleaseVersion.substr(0, 10);
+ return this.error.firstReleaseVersion?.substr(0, 10);
},
shortLastReleaseVersion() {
- return this.error.lastReleaseVersion.substr(0, 10);
+ return this.error.lastReleaseVersion?.substr(0, 10);
},
shortGitlabCommit() {
- return this.error.gitlabCommit.substr(0, 10);
+ return this.error.gitlabCommit?.substr(0, 10);
},
},
methods: {
@@ -72,11 +66,11 @@ export default {
data-testid="error-count-card"
>
<template #header>
- <span>{{ __('Events') }}</span>
+ {{ __('Events') }}
</template>
<template #default>
- <span>{{ error.count }}</span>
+ {{ error.count }}
</template>
</gl-card>
@@ -87,61 +81,66 @@ export default {
data-testid="user-count-card"
>
<template #header>
- <span>{{ __('Users') }}</span>
+ {{ __('Users') }}
</template>
<template #default>
- <span>{{ error.userCount }}</span>
+ {{ error.userCount }}
</template>
</gl-card>
<gl-card
- v-if="error.firstReleaseVersion"
+ v-if="error.firstSeen"
:class="$options.CARD_CLASS"
:body-class="$options.BODY_CLASS"
:header-class="$options.HEADER_CLASS"
data-testid="first-release-card"
>
<template #header>
- <gl-icon v-gl-tooltip :title="shortFirstReleaseVersion" name="commit" class="gl-mr-1" />
- <span>{{ __('First seen') }}</span>
+ {{ __('First seen') }}
</template>
- <template #default>
- <gl-link v-if="error.integrated" :href="firstCommitLink" class="gl-font-lg">
- <time-ago-tooltip :time="error.firstSeen" />
- </gl-link>
+ <template v-if="error.integrated" #default>
+ <time-ago-tooltip :time="error.firstSeen" />
+ <span v-if="shortFirstReleaseVersion" class="gl-font-sm gl-text-secondary">
+ <gl-icon name="commit" class="gl-mr-1" :size="12" />{{ shortFirstReleaseVersion }}
+ </span>
+ </template>
- <gl-link v-else :href="firstReleaseLink" target="_blank" class="gl-font-lg">
+ <template v-else #default>
+ <gl-link :href="firstReleaseLink" target="_blank" class="gl-font-lg">
<time-ago-tooltip :time="error.firstSeen" />
</gl-link>
</template>
</gl-card>
<gl-card
- v-if="error.lastReleaseVersion"
+ v-if="error.lastSeen"
:class="$options.CARD_CLASS"
:body-class="$options.BODY_CLASS"
:header-class="$options.HEADER_CLASS"
data-testid="last-release-card"
>
<template #header>
- <gl-icon v-gl-tooltip :title="shortLastReleaseVersion" name="commit" class="gl-mr-1" />
{{ __('Last seen') }}
</template>
- <template #default>
- <gl-link v-if="error.integrated" :href="lastCommitLink" class="gl-font-lg">
- <time-ago-tooltip :time="error.lastSeen" />
- </gl-link>
- <gl-link v-else :href="lastReleaseLink" target="_blank" class="gl-font-lg">
+ <template v-if="error.integrated" #default>
+ <time-ago-tooltip :time="error.lastSeen" />
+ <span v-if="shortLastReleaseVersion" class="gl-font-sm gl-text-secondary">
+ <gl-icon name="commit" class="gl-mr-1" :size="12" />{{ shortLastReleaseVersion }}
+ </span>
+ </template>
+
+ <template v-else #default>
+ <gl-link :href="lastReleaseLink" target="_blank" class="gl-font-lg">
<time-ago-tooltip :time="error.lastSeen" />
</gl-link>
</template>
</gl-card>
<gl-card
- v-if="error.gitlabCommit"
+ v-if="!error.integrated && error.gitlabCommit"
:class="$options.CARD_CLASS"
:body-class="$options.BODY_CLASS"
:header-class="$options.HEADER_CLASS"
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
index a5e712f4fc2..35e8e26ecfb 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
@@ -44,37 +44,45 @@ export default {
<template>
<div>
- <gl-button-group class="gl-flex-direction-column flex-md-row gl-ml-0 ml-md-n4">
+ <gl-button-group class="gl-flex-direction-column gl-md-flex-direction-row gl-ml-n6">
<gl-button
:key="ignoreBtn.status"
:ref="`${ignoreBtn.title.toLowerCase()}Error`"
v-gl-tooltip.hover
- class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
+ class="gl-display-block gl-mb-4 gl-md-mb-0 gl-w-full"
:title="ignoreBtn.title"
:aria-label="ignoreBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })"
>
- <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" />
- <span class="d-md-none">{{ ignoreBtn.title }}</span>
+ <gl-icon
+ class="gl-display-none gl-md-display-inline gl-m-0"
+ :name="ignoreBtn.icon"
+ :size="12"
+ />
+ <span class="gl-md-display-none">{{ ignoreBtn.title }}</span>
</gl-button>
<gl-button
:key="resolveBtn.status"
:ref="`${resolveBtn.title.toLowerCase()}Error`"
v-gl-tooltip.hover
- class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
+ class="gl-display-block gl-mb-4 gl-md-mb-0 gl-w-full"
:title="resolveBtn.title"
:aria-label="resolveBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })"
>
- <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" />
- <span class="d-md-none">{{ resolveBtn.title }}</span>
+ <gl-icon
+ class="gl-display-none gl-md-display-inline gl-m-0"
+ :name="resolveBtn.icon"
+ :size="12"
+ />
+ <span class="gl-md-display-none">{{ resolveBtn.title }}</span>
</gl-button>
</gl-button-group>
<gl-button
:href="detailsLink"
category="primary"
variant="confirm"
- class="gl-display-block d-md-none gl-mb-4 mb-md-0"
+ class="gl-display-block gl-md-display-none! gl-mb-4 gl-md-mb-0"
>
{{ __('More details') }}
</gl-button>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 6750f0f5411..0c9a98f3b33 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -20,7 +20,6 @@ import { mapActions, mapState } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
import AccessorUtils from '~/lib/utils/accessor';
import { __ } from '~/locale';
-import Tracking from '~/tracking';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { sanitizeUrl } from '~/lib/utils/url_utility';
import {
@@ -31,12 +30,12 @@ import {
} from '../events_tracking';
import { I18N_ERROR_TRACKING_LIST } from '../constants';
import ErrorTrackingActions from './error_tracking_actions.vue';
-
-export const tableDataClass = 'table-col d-flex d-md-table-cell align-items-center';
+import TimelineChart from './timeline_chart.vue';
const isValidErrorId = (errorId) => {
return /^[0-9]+$/.test(errorId);
};
+export const tableDataClass = 'gl-display-flex gl-md-display-table-cell gl-align-items-center';
export default {
FIRST_PAGE: 1,
PREV_PAGE: 1,
@@ -46,26 +45,32 @@ export default {
{
key: 'error',
label: __('Error'),
- thClass: 'w-60p',
- tdClass: `${tableDataClass} px-3 rounded-top`,
+ thClass: 'gl-w-40p',
+ tdClass: `${tableDataClass}`,
+ },
+ {
+ key: 'timeline',
+ label: __('Timeline'),
+ thClass: 'gl-text-center gl-w-20p',
+ tdClass: `${tableDataClass} gl-text-center`,
},
{
key: 'events',
label: __('Events'),
- thClass: 'text-right',
- tdClass: `${tableDataClass}`,
+ thClass: 'gl-text-center gl-w-10p',
+ tdClass: `${tableDataClass} gl-text-center`,
},
{
key: 'users',
label: __('Users'),
- thClass: 'text-right',
- tdClass: `${tableDataClass}`,
+ thClass: 'gl-text-center gl-w-10p',
+ tdClass: `${tableDataClass} gl-text-center`,
},
{
key: 'lastSeen',
label: __('Last seen'),
- thClass: 'w-15p',
- tdClass: `${tableDataClass}`,
+ thClass: 'gl-text-center gl-w-10p',
+ tdClass: `${tableDataClass} gl-text-center`,
},
{
key: 'status',
@@ -99,6 +104,7 @@ export default {
GlPagination,
TimeAgo,
ErrorTrackingActions,
+ TimelineChart,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -116,6 +122,10 @@ export default {
type: Boolean,
required: true,
},
+ integratedErrorTrackingEnabled: {
+ type: Boolean,
+ required: true,
+ },
illustrationPath: {
type: String,
required: true,
@@ -180,7 +190,6 @@ export default {
},
},
epicLink: 'https://gitlab.com/gitlab-org/gitlab/-/issues/353639',
- openBetaLink: 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta',
featureFlagLink: helpPagePath('operations/error_tracking'),
created() {
if (this.errorTrackingEnabled) {
@@ -242,13 +251,11 @@ export default {
},
filterErrors(status, label) {
this.filterValue = label;
- const { category, action } = trackErrorStatusFilterOptions(status);
- Tracking.event(category, action);
+ trackErrorStatusFilterOptions(status, this.integratedErrorTrackingEnabled);
return this.filterByStatus(status);
},
sortErrorsByField(field) {
- const { category, action } = trackErrorSortedByField(field);
- Tracking.event(category, action);
+ trackErrorSortedByField(field, this.integratedErrorTrackingEnabled);
return this.sortByField(field);
},
updateErrosStatus({ errorId, status }) {
@@ -263,12 +270,10 @@ export default {
this.removeIgnoredResolvedErrors(errorId);
},
trackPageViews() {
- const { category, action } = trackErrorListViewsOptions;
- Tracking.event(category, action);
+ trackErrorListViewsOptions(this.integratedErrorTrackingEnabled);
},
trackStatusUpdate(status) {
- const { category, action } = trackErrorStatusUpdateOptions(status);
- Tracking.event(category, action);
+ trackErrorStatusUpdateOptions(status, this.integratedErrorTrackingEnabled);
},
},
};
@@ -277,6 +282,7 @@ export default {
<template>
<div class="error-list">
<div v-if="errorTrackingEnabled">
+ <!-- Enable ET -->
<gl-alert
v-if="showIntegratedDisabledAlert"
variant="danger"
@@ -305,18 +311,20 @@ export default {
</gl-button>
</div>
</gl-alert>
+
+ <!-- Search / Filter Bar -->
<div
- class="row flex-column flex-md-row align-items-md-center m-0 mt-sm-2 p-3 p-sm-3 bg-secondary border"
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-md-align-items-center gl-m-0 gl-p-5 gl-bg-gray-50 gl-border"
>
- <div class="search-box flex-fill mb-1 mb-md-0">
- <div class="filtered-search-box mb-0">
+ <div class="search-box gl-display-flex gl-flex-grow-1 gl-mb-2 gl-md-mb-0">
+ <div class="filtered-search-box gl-mb-0">
<gl-dropdown
:text="__('Recent searches')"
class="filtered-search-history-dropdown-wrapper"
toggle-class="filtered-search-history-dropdown-toggle-button gl-shadow-none! gl-border-r-gray-200! gl-border-1! gl-rounded-0!"
:disabled="loading"
>
- <div v-if="!$options.hasLocalStorage" class="px-3">
+ <div v-if="!$options.hasLocalStorage" class="gl-px-5">
{{ __('This feature requires local storage to be enabled') }}
</div>
<template v-else-if="recentSearches.length > 0">
@@ -331,12 +339,12 @@ export default {
>{{ __('Clear recent searches') }}
</gl-dropdown-item>
</template>
- <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
+ <div v-else class="gl-px-5">{{ __("You don't have any recent searches") }}</div>
</gl-dropdown>
<div class="filtered-search-input-container gl-flex-grow-1">
<gl-form-input
v-model="errorSearchQuery"
- class="pl-2 filtered-search"
+ class="gl-pl-3! filtered-search"
:disabled="loading"
:placeholder="__('Search or filter results…')"
autofocus
@@ -348,7 +356,7 @@ export default {
v-if="errorSearchQuery.length > 0"
v-gl-tooltip.hover
:title="__('Clear')"
- class="clear-search text-secondary"
+ class="clear-search gl-text-secondary"
name="clear"
icon="close"
@click="errorSearchQuery = ''"
@@ -359,7 +367,7 @@ export default {
<gl-dropdown
:text="$options.statusFilters[statusFilter]"
- class="status-dropdown mx-md-1 mb-1 mb-md-0"
+ class="status-dropdown gl-md-ml-2 gl-md-mr-2 gl-mb-2 gl-md-mb-0"
:disabled="loading"
right
>
@@ -368,7 +376,7 @@ export default {
:key="status"
@click="filterErrors(status, label)"
>
- <span class="d-flex">
+ <span class="gl-display-flex">
<gl-icon
class="gl-dropdown-item-check-icon"
:class="{ invisible: !isCurrentStatusFilter(status) }"
@@ -385,7 +393,7 @@ export default {
:key="field"
@click="sortErrorsByField(field)"
>
- <span class="d-flex">
+ <span class="gl-display-flex">
<gl-icon
class="gl-dropdown-item-check-icon"
:class="{ invisible: !isCurrentSortField(field) }"
@@ -397,58 +405,73 @@ export default {
</gl-dropdown>
</div>
- <div v-if="loading" class="py-3">
+ <div v-if="loading" class="gl-py-5">
<gl-loading-icon size="lg" />
</div>
+ <!-- Results Table -->
<template v-else>
- <h4 class="d-block d-md-none my-3">{{ __('Open errors') }}</h4>
+ <h4 class="gl-display-block gl-md-display-none! gl-my-5">{{ __('Open errors') }}</h4>
<gl-table
- class="error-list-table mt-3"
+ class="error-list-table gl-mt-5"
:items="errors"
:fields="$options.fields"
:show-empty="true"
fixed
stacked="md"
- tbody-tr-class="table-row mb-4"
+ tbody-tr-class="table-row"
>
+ <!-- table head -->
<template #head(error)>
- <div class="d-none d-md-block">{{ __('Open errors') }}</div>
+ <div class="gl-display-none gl-md-display-block">{{ __('Open errors') }}</div>
</template>
<template #head(events)="data">
- <div class="text-md-right">{{ data.label }}</div>
+ {{ data.label }}
</template>
<template #head(users)="data">
- <div class="text-md-right">{{ data.label }}</div>
+ {{ data.label }}
</template>
+ <!-- table row -->
<template #cell(error)="errors">
- <div class="d-flex flex-column">
- <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)">
- <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <gl-link
+ class="gl-display-flex gl-max-w-full gl-text-body"
+ :href="getDetailsLink(errors.item.id)"
+ >
+ <strong class="gl-text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link>
- <span class="text-secondary text-truncate mw-100">
+ <span class="gl-text-secondary gl-text-truncate gl-max-w-full">
{{ errors.item.culprit }}
</span>
</div>
</template>
+
+ <template #cell(timeline)="errors">
+ <timeline-chart
+ v-if="errors.item.frequency"
+ :timeline-data="errors.item.frequency"
+ :height="70"
+ />
+ </template>
+
<template #cell(events)="errors">
- <div class="text-right">{{ errors.item.count }}</div>
+ {{ errors.item.count }}
</template>
<template #cell(users)="errors">
- <div class="text-right">{{ errors.item.userCount }}</div>
+ {{ errors.item.userCount }}
</template>
<template #cell(lastSeen)="errors">
- <div class="text-lg-left text-right">
- <time-ago :time="errors.item.lastSeen" class="text-secondary" />
- </div>
+ <time-ago :time="errors.item.lastSeen" class="gl-text-secondary" />
</template>
+
<template #cell(status)="errors">
<error-tracking-actions :error="errors.item" @update-issue-status="updateErrosStatus" />
</template>
+
<template #empty>
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
@@ -467,6 +490,7 @@ export default {
/>
</template>
</div>
+ <!-- Get Started with ET -->
<div v-else>
<gl-empty-state :title="__('Get started with error tracking')" :svg-path="illustrationPath">
<template #description>
@@ -476,10 +500,6 @@ export default {
__('How do I get started?')
}}</gl-link>
</div>
- <div class="gl-mt-3">
- <span>{{ __('Error tracking is currently in') }}</span>
- <gl-link target="_blank" :href="$options.openBetaLink">{{ __('Open Beta.') }}</gl-link>
- </div>
</template>
</gl-empty-state>
</div>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue
index f58d54f2933..54b9d37be73 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue
@@ -25,7 +25,7 @@ export default {
v-for="(entry, index) in entries"
:key="`stacktrace-entry-${index}`"
:lines="entry.context"
- :file-path="entry.filename"
+ :file-path="entry.filename || entry.abs_path"
:error-line="entry.lineNo"
:error-fn="entry.function"
:error-column="entry.colNo"
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 6ddd982ebf1..bf549063031 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlTooltip, GlSprintf, GlIcon, GlTruncate } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -10,6 +10,7 @@ export default {
FileIcon,
GlIcon,
GlSprintf,
+ GlTruncate,
},
directives: {
GlTooltip,
@@ -22,7 +23,8 @@ export default {
},
filePath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
errorFn: {
type: String,
@@ -79,26 +81,23 @@ export default {
<template>
<div class="file-holder">
<div ref="header" class="file-title file-title-flex-parent">
- <div class="file-header-content d-flex align-content-center">
+ <div class="file-header-content d-flex align-content-center gl-flex-wrap overflow-hidden">
<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="16" aria-hidden="true" css-classes="gl-mr-2" />
- <strong
- v-gl-tooltip
- :title="filePath"
- class="file-title-name d-inline-block overflow-hidden text-truncate limited-width"
- data-container="body"
- >
- {{ filePath }}
- </strong>
- <clipboard-button
- :title="__('Copy file path')"
- :text="filePath"
- category="tertiary"
- size="small"
- css-class="gl-mr-1"
- />
+ <template v-if="filePath">
+ <file-icon :file-name="filePath" :size="16" aria-hidden="true" css-classes="gl-mr-2" />
+ <strong class="file-title-name d-inline-block overflow-hidden limited-width">
+ <gl-truncate with-tooltip :text="filePath" position="middle" />
+ </strong>
+ <clipboard-button
+ :title="__('Copy file path')"
+ :text="filePath"
+ category="tertiary"
+ size="small"
+ css-class="gl-mr-1"
+ />
+ </template>
<gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')">
<template #span="{ content }">
diff --git a/app/assets/javascripts/error_tracking/components/timeline_chart.vue b/app/assets/javascripts/error_tracking/components/timeline_chart.vue
new file mode 100644
index 00000000000..51e0c900e4b
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/timeline_chart.vue
@@ -0,0 +1,129 @@
+<script>
+import { GlChart } from '@gitlab/ui/dist/charts';
+import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables';
+import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
+import { isNumber } from 'lodash';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+import { logError } from '~/lib/logger';
+
+function parseTimelineData(timelineData) {
+ const xData = [];
+ const yData = [];
+ const invalidDataPoints = [];
+ timelineData.forEach((f) => {
+ let rawDate;
+ let count;
+
+ if (Array.isArray(f)) {
+ [rawDate, count] = f;
+ } else if (f.count !== undefined && f.time !== undefined) {
+ rawDate = f.time;
+ count = f.count;
+ }
+ if (rawDate !== undefined && count !== undefined) {
+ // dates/timestamps are in seconds
+ const date = isNumber(rawDate) ? rawDate * 1000 : rawDate;
+ xData.push(formatDate(date));
+ yData.push(count);
+ } else {
+ invalidDataPoints.push(f);
+ }
+ });
+ if (invalidDataPoints.length > 0) {
+ // only log up to 5 invalid data points to reduce log size
+ logError(`Found invalid data points ${invalidDataPoints.slice(0, 5)}`);
+ }
+ return { xData, yData };
+}
+
+export default {
+ components: {
+ GlChart,
+ },
+ props: {
+ timelineData: {
+ /**
+ * Array items can be:
+ * touples: [a_date: string | number, a_count: number]
+ * objects: {time: a_date, count: a_count}: {time: string | number, count: number}
+ *
+ * Dates can either be string or number/timestamp.
+ * When dates are timestamps, they are expected in seconds.
+ *
+ */
+ type: Array,
+ required: true,
+ validator(value) {
+ for (const item of value) {
+ if (Array.isArray(item)) {
+ if (item.length !== 2 || !isNumber(item[1])) {
+ return false;
+ }
+ } else if (typeof item === 'object') {
+ if (!('time' in item) || !('count' in item)) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ return true;
+ },
+ },
+ height: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ chartOptions() {
+ if (!this.timelineData) {
+ return {};
+ }
+ const { xData, yData } = parseTimelineData(this.timelineData);
+
+ return {
+ xAxis: {
+ type: 'category',
+ data: xData,
+ show: true,
+ axisTick: {
+ show: false,
+ },
+ axisLabel: {
+ show: false,
+ },
+ axisLine: {
+ show: true,
+ lineStyle: {
+ width: 1,
+ color: '#ececec',
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ show: false,
+ },
+ series: [
+ {
+ data: yData,
+ type: 'bar',
+ itemStyle: { color: hexToRgba(dataVizBlue500, 0.5) },
+ },
+ ],
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: {
+ type: 'shadow',
+ },
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-chart v-if="timelineData" :options="chartOptions" :height="height" />
+</template>
diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js
index 37b8007d556..04bb50ab733 100644
--- a/app/assets/javascripts/error_tracking/details.js
+++ b/app/assets/javascripts/error_tracking/details.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import csrf from '~/lib/utils/csrf';
+import { parseBoolean } from '~/lib/utils/common_utils';
import ErrorDetails from './components/error_details.vue';
import store from './store';
@@ -19,6 +20,9 @@ export default () => {
projectIssuesPath,
} = domEl.dataset;
+ let { integratedErrorTrackingEnabled } = domEl.dataset;
+ integratedErrorTrackingEnabled = parseBoolean(integratedErrorTrackingEnabled);
+
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
@@ -40,6 +44,7 @@ export default () => {
issueStackTracePath,
projectIssuesPath,
csrfToken: csrf.token,
+ integratedErrorTrackingEnabled,
},
});
},
diff --git a/app/assets/javascripts/error_tracking/events_tracking.js b/app/assets/javascripts/error_tracking/events_tracking.js
index aaef274d0cd..eb38fe6542b 100644
--- a/app/assets/javascripts/error_tracking/events_tracking.js
+++ b/app/assets/javascripts/error_tracking/events_tracking.js
@@ -1,5 +1,15 @@
+import Tracking from '~/tracking';
+
const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings
+function sendTrackingEvents(action, integrated) {
+ Tracking.event(category, action, {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+}
+
/**
* Tracks snowplow event when User clicks on error link to Sentry
* @param {String} externalUrl that will be send as a property for the event
@@ -14,47 +24,42 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({
/**
* Tracks snowplow event when user views error list
*/
-export const trackErrorListViewsOptions = {
- category,
- action: 'view_errors_list',
+
+export const trackErrorListViewsOptions = (integrated) => {
+ sendTrackingEvents('view_errors_list', integrated);
};
/**
* Tracks snowplow event when user views error details
*/
-export const trackErrorDetailsViewsOptions = {
- category,
- action: 'view_error_details',
+export const trackErrorDetailsViewsOptions = (integrated) => {
+ sendTrackingEvents('view_error_details', integrated);
};
/**
* Tracks snowplow event when error status is updated
*/
-export const trackErrorStatusUpdateOptions = (status) => ({
- category,
- action: `update_${status}_status`,
-});
+export const trackErrorStatusUpdateOptions = (status, integrated) => {
+ sendTrackingEvents(`update_${status}_status`, integrated);
+};
/**
* Tracks snowplow event when error list is filter by status
*/
-export const trackErrorStatusFilterOptions = (status) => ({
- category,
- action: `filter_${status}_status`,
-});
+export const trackErrorStatusFilterOptions = (status, integrated) => {
+ sendTrackingEvents(`filter_${status}_status`, integrated);
+};
/**
* Tracks snowplow event when error list is sorted by field
*/
-export const trackErrorSortedByField = (field) => ({
- category,
- action: `sort_by_${field}`,
-});
+export const trackErrorSortedByField = (field, integrated) => {
+ sendTrackingEvents(`sort_by_${field}`, integrated);
+};
/**
* Tracks snowplow event when the Create Issue button is clicked
*/
-export const trackCreateIssueFromError = {
- category,
- action: 'click_create_issue_from_error',
+export const trackCreateIssueFromError = (integrated) => {
+ sendTrackingEvents('click_create_issue_from_error', integrated);
};
diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js
index 8b2086e1522..9805aed681d 100644
--- a/app/assets/javascripts/error_tracking/list.js
+++ b/app/assets/javascripts/error_tracking/list.js
@@ -18,10 +18,12 @@ export default () => {
errorTrackingEnabled,
userCanEnableErrorTracking,
showIntegratedTrackingDisabledAlert,
+ integratedErrorTrackingEnabled,
} = domEl.dataset;
errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking);
+ integratedErrorTrackingEnabled = parseBoolean(integratedErrorTrackingEnabled);
showIntegratedTrackingDisabledAlert = parseBoolean(showIntegratedTrackingDisabledAlert);
// eslint-disable-next-line no-new
@@ -42,6 +44,7 @@ export default () => {
projectPath,
listPath,
showIntegratedTrackingDisabledAlert,
+ integratedErrorTrackingEnabled,
},
});
},
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index dd21b0f9c92..5745491c32d 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -20,6 +20,10 @@ query errorDetails($fullPath: ID!, $errorId: GitlabErrorTrackingDetailedErrorID!
externalUrl
externalBaseUrl
firstReleaseVersion
+ frequency {
+ count
+ time
+ }
lastReleaseVersion
gitlabCommit
gitlabCommitPath
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 8b79c661b12..35abcc3d561 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -157,16 +157,18 @@ export default {
<template>
<form class="feature-flags-form">
<fieldset>
- <div class="row">
- <div class="form-group col-md-4">
- <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }} *</label>
+ <div class="gl-display-flex gl-flex-wrap gl-mx-n5">
+ <div class="gl-mb-5 gl-px-5 gl-w-full col-md-4">
+ <label for="feature-flag-name" class="gl-font-weight-bold"
+ >{{ s__('FeatureFlags|Name') }} *</label
+ >
<input id="feature-flag-name" v-model="formName" class="form-control" />
</div>
</div>
- <div class="row">
- <div class="form-group col-md-4">
- <label for="feature-flag-description" class="label-bold">
+ <div class="gl-display-flex gl-flex-wrap gl-mx-n5">
+ <div class="gl-mb-5 gl-px-5 gl-w-full col-md-4">
+ <label for="feature-flag-description" class="gl-font-weight-bold">
{{ s__('FeatureFlags|Description') }}
</label>
<textarea
@@ -185,8 +187,8 @@ export default {
:show-categorized-issues="false"
/>
- <div class="row">
- <div class="col-md-12">
+ <div class="gl-display-flex gl-flex-wrap gl-mx-n5">
+ <div class="gl-mb-5 gl-px-5 gl-w-full">
<h4>{{ s__('FeatureFlags|Strategies') }}</h4>
<div class="gl-display-flex gl-align-items-baseline gl-justify-content-space-between">
<p class="gl-mr-5">{{ $options.translations.newHelpText }}</p>
@@ -206,7 +208,7 @@ export default {
@delete="deleteStrategy(strategy)"
/>
</div>
- <div v-else class="gl-display-flex gl-justify-content-center gl-border-t gl-py-6 w-100">
+ <div v-else class="gl-display-flex gl-justify-content-center gl-border-t gl-py-6 gl-w-full">
<span>{{ $options.translations.noStrategiesText }}</span>
</div>
</fieldset>
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 9dbffe75f6b..53745d3b021 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
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
@@ -11,10 +11,7 @@ const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter']
export default {
components: {
- GlDropdown,
- GlDropdownItem,
- GlLoadingIcon,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
ParameterFormGroup,
},
props: {
@@ -38,24 +35,25 @@ export default {
dropdownText() {
return this.strategy?.userList?.name ?? this.$options.translations.defaultDropdownText;
},
+ listboxItems() {
+ return this.userLists.map((list) => ({
+ value: list.id,
+ text: list.name,
+ }));
+ },
},
mounted() {
fetchUserLists.apply(this);
},
+
methods: {
setFilter: debounce(setFilter, 250),
- fetchUserLists: debounce(fetchUserLists, 250),
- onUserListChange(list) {
+ onUserListChange(listId) {
+ const list = this.userLists.find((userList) => userList.id === listId);
this.$emit('change', {
userList: list,
});
},
- isSelectedUserList({ id }) {
- return id === this.userListId;
- },
- setFocus() {
- this.$refs.searchBox.focusInput();
- },
},
};
</script>
@@ -67,26 +65,16 @@ export default {
:description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
>
<template #default="{ inputId }">
- <gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus">
- <gl-search-box-by-type
- ref="searchBox"
- class="gl-m-3"
- :value="filter"
- @input="setFilter"
- @focus="fetchUserLists"
- @keyup="fetchUserLists"
- />
- <gl-loading-icon v-if="isLoading" size="sm" />
- <gl-dropdown-item
- v-for="list in userLists"
- :key="list.id"
- :is-checked="isSelectedUserList(list)"
- is-check-item
- @click="onUserListChange(list)"
- >
- {{ list.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :id="inputId"
+ :toggle-text="dropdownText"
+ :loading="isLoading"
+ :items="listboxItems"
+ searchable
+ :selected="userListId"
+ @select="onUserListChange"
+ @search="setFilter"
+ />
</template>
</parameter-form-group>
</template>
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
index 1c6e6380e76..24f7d567ea7 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
@@ -1,5 +1,5 @@
<script>
-import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg';
+import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg?raw';
import { GlPopover, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/google_cloud/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue
index 80bd6ef28fb..f0489ab1f5b 100644
--- a/app/assets/javascripts/google_cloud/databases/service_table.vue
+++ b/app/assets/javascripts/google_cloud/databases/service_table.vue
@@ -48,7 +48,7 @@ const i18n = {
),
};
-const helpUrlSecrets = helpPagePath('ee/ci/secrets');
+const helpUrlSecrets = helpPagePath('ci/secrets/index');
export default {
components: { GlAlert, GlButton, GlLink, GlSprintf, GlTable },
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
deleted file mode 100644
index 3911201457f..00000000000
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-<script>
-import {
- GlButton,
- GlFormGroup,
- GlFormInput,
- GlFormCheckbox,
- GlIcon,
- GlLink,
- GlSprintf,
-} from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { helpPagePath } from '~/helpers/help_page_helper';
-
-export default {
- components: {
- GlButton,
- GlFormCheckbox,
- GlFormGroup,
- GlFormInput,
- GlIcon,
- GlLink,
- GlSprintf,
- },
- data() {
- return {
- helpUrl: helpPagePath('operations/metrics/embed_grafana', {
- anchor: 'use-integration-with-grafana-api',
- }),
- placeholderUrl: 'https://my-grafana.example.com/',
- };
- },
- computed: {
- ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']),
- integrationEnabled: {
- get() {
- return this.grafanaEnabled;
- },
- set(grafanaEnabled) {
- this.setGrafanaEnabled(grafanaEnabled);
- },
- },
- localGrafanaToken: {
- get() {
- return this.grafanaToken;
- },
- set(token) {
- this.setGrafanaToken(token);
- },
- },
- localGrafanaUrl: {
- get() {
- return this.grafanaUrl;
- },
- set(url) {
- this.setGrafanaUrl(url);
- },
- },
- },
- methods: {
- ...mapActions([
- 'setGrafanaUrl',
- 'setGrafanaToken',
- 'setGrafanaEnabled',
- 'updateGrafanaIntegration',
- ]),
- },
-};
-</script>
-
-<template>
- <section id="grafana" class="settings no-animate js-grafana-integration">
- <div class="settings-header">
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
- {{ s__('GrafanaIntegration|Grafana authentication') }}
- </h4>
- <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
- <p class="js-section-sub-header">
- {{
- s__(
- 'GrafanaIntegration|Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.',
- )
- }}
- <gl-link :href="helpUrl">{{ __('Learn more.') }}</gl-link>
- </p>
- </div>
- <div class="settings-content">
- <form>
- <gl-form-group :label="__('Enable authentication')" label-for="grafana-integration-enabled">
- <gl-form-checkbox id="grafana-integration-enabled" v-model="integrationEnabled">
- {{ s__('GrafanaIntegration|Active') }}
- </gl-form-checkbox>
- </gl-form-group>
- <gl-form-group
- :label="s__('GrafanaIntegration|Grafana URL')"
- label-for="grafana-url"
- :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')"
- >
- <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" />
- </gl-form-group>
- <gl-form-group :label="s__('GrafanaIntegration|API token')" label-for="grafana-token">
- <gl-form-input id="grafana-token" v-model="localGrafanaToken" />
- <p class="form-text text-muted">
- <gl-sprintf
- :message="
- s__('GrafanaIntegration|Enter the %{docLinkStart}Grafana API token%{docLinkEnd}.')
- "
- >
- <template #docLink="{ content }">
- <gl-link
- href="https://grafana.com/docs/http_api/auth/#create-api-token"
- target="_blank"
- >{{ content }} <gl-icon name="external-link" class="gl-vertical-align-middle"
- /></gl-link>
- </template>
- </gl-sprintf>
- </p>
- </gl-form-group>
- <gl-button
- variant="confirm"
- category="primary"
- data-testid="save-grafana-settings-button"
- @click="updateGrafanaIntegration"
- >
- {{ __('Save changes') }}
- </gl-button>
- </form>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js
deleted file mode 100644
index 9ade29dae69..00000000000
--- a/app/assets/javascripts/grafana_integration/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import GrafanaIntegration from './components/grafana_integration.vue';
-import store from './store';
-
-export default () => {
- const el = document.querySelector('.js-grafana-integration');
-
- if (!el) return false;
-
- return new Vue({
- el,
- store: store(el.dataset),
- render(createElement) {
- return createElement(GrafanaIntegration);
- },
- });
-};
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js
deleted file mode 100644
index 76e21f09719..00000000000
--- a/app/assets/javascripts/grafana_integration/store/actions.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import * as mutationTypes from './mutation_types';
-
-export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url);
-
-export const setGrafanaToken = ({ commit }, token) =>
- commit(mutationTypes.SET_GRAFANA_TOKEN, token);
-
-export const setGrafanaEnabled = ({ commit }, enabled) =>
- commit(mutationTypes.SET_GRAFANA_ENABLED, enabled);
-
-export const updateGrafanaIntegration = ({ state, dispatch }) =>
- axios
- .patch(state.operationsSettingsEndpoint, {
- project: {
- grafana_integration_attributes: {
- grafana_url: state.grafanaUrl,
- token: state.grafanaToken,
- enabled: state.grafanaEnabled,
- },
- },
- })
- .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess'))
- .catch((error) => dispatch('receiveGrafanaIntegrationUpdateError', error));
-
-export const receiveGrafanaIntegrationUpdateSuccess = () => {
- /**
- * The operations_controller currently handles successful requests
- * by creating an alert banner message to notify the user.
- */
- refreshCurrentPage();
-};
-
-export const receiveGrafanaIntegrationUpdateError = (_, error) => {
- const { response } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- createAlert({
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
-};
diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js
deleted file mode 100644
index a11bd8089fd..00000000000
--- a/app/assets/javascripts/grafana_integration/store/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-Vue.use(Vuex);
-
-export const createStore = (initialState) =>
- new Vuex.Store({
- state: createState(initialState),
- actions,
- mutations,
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js
deleted file mode 100644
index 314c3a4039a..00000000000
--- a/app/assets/javascripts/grafana_integration/store/mutation_types.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export const SET_GRAFANA_URL = 'SET_GRAFANA_URL';
-export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN';
-export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED';
diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js
deleted file mode 100644
index 0992030d404..00000000000
--- a/app/assets/javascripts/grafana_integration/store/mutations.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.SET_GRAFANA_URL](state, url) {
- state.grafanaUrl = url;
- },
- [types.SET_GRAFANA_TOKEN](state, token) {
- state.grafanaToken = token;
- },
- [types.SET_GRAFANA_ENABLED](state, enabled) {
- state.grafanaEnabled = enabled;
- },
-};
diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js
deleted file mode 100644
index a912eb58327..00000000000
--- a/app/assets/javascripts/grafana_integration/store/state.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default (initialState = {}) => ({
- operationsSettingsEndpoint: initialState.operationsSettingsEndpoint,
- grafanaToken: initialState.grafanaIntegrationToken || '',
- grafanaUrl: initialState.grafanaIntegrationUrl || '',
- grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false,
-});
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index d0b0a485fe6..ae7676a3e9e 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -6,8 +6,6 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-import { findHierarchyWidgetChildren } from '~/work_items/utils';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export const config = {
@@ -47,6 +45,16 @@ export const config = {
},
},
},
+ DescriptionVersion: {
+ fields: {
+ startVersionId: {
+ read() {
+ // we need to set this when fetching the diff in the last 10 mins , the starting diff will be the very first one , so need to save it
+ return '';
+ },
+ },
+ },
+ },
WorkItem: {
fields: {
// widgets policy because otherwise the subscriptions invalidate the cache
@@ -82,14 +90,6 @@ export const config = {
});
},
},
- userPermissions: {
- read(permission = {}) {
- return {
- ...permission,
- setWorkItemMetadata: false,
- };
- },
- },
},
},
MemberInterfaceConnection: {
@@ -181,28 +181,6 @@ export const config = {
export const resolvers = {
Mutation: {
- addHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: workItemQuery, variables: { id } };
- const sourceData = cache.readQuery(queryArgs);
-
- const data = produce(sourceData, (draftState) => {
- findHierarchyWidgetChildren(draftState.workItem).push(workItem);
- });
-
- cache.writeQuery({ ...queryArgs, data });
- },
- removeHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: workItemQuery, variables: { id } };
- const sourceData = cache.readQuery(queryArgs);
-
- const data = produce(sourceData, (draftState) => {
- const hierarchyChildren = findHierarchyWidgetChildren(draftState.workItem);
- const index = hierarchyChildren.findIndex((child) => child.id === workItem.id);
- hierarchyChildren.splice(index, 1);
- });
-
- cache.writeQuery({ ...queryArgs, data });
- },
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
const data = produce(sourceData, (draftData) => {
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index f35886716ee..7651bbba71c 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -3,6 +3,10 @@
"AlertManagementHttpIntegration",
"AlertManagementPrometheusIntegration"
],
+ "BaseHeaderInterface": [
+ "AuditEventStreamingHeader",
+ "AuditEventsStreamingInstanceHeader"
+ ],
"CiVariable": [
"CiGroupVariable",
"CiInstanceVariable",
@@ -94,6 +98,7 @@
"ContainerRepositoryRegistry",
"DependencyProxyBlobRegistry",
"DependencyProxyManifestRegistry",
+ "DesignManagementRepositoryRegistry",
"JobArtifactRegistry",
"LfsObjectRegistry",
"MergeRequestDiffRegistry",
@@ -150,6 +155,7 @@
"VulnerabilityDetailList",
"VulnerabilityDetailMarkdown",
"VulnerabilityDetailModuleLocation",
+ "VulnerabilityDetailNamedList",
"VulnerabilityDetailTable",
"VulnerabilityDetailText",
"VulnerabilityDetailUrl"
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index c05b4a5950c..c59a061597c 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -6,7 +6,7 @@ query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $firs
id
users: projectMembers(
search: $search
- relations: [DIRECT, INHERITED, INVITED_GROUPS]
+ relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS]
first: $first
after: $after
sort: USER_FULL_NAME_ASC
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
index 5a589b094de..8fa786535c8 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql
@@ -10,7 +10,7 @@ query projectUsersSearchWithMRPermissions(
id
users: projectMembers(
search: $search
- relations: [DIRECT, INHERITED, INVITED_GROUPS]
+ relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS]
sort: USER_FULL_NAME_ASC
) {
nodes {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 82eddf5603f..ebfffdaaf50 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -41,10 +41,6 @@ export default {
type: Object,
required: true,
},
- hideProjects: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 1f9fc68a612..8d193310a98 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -56,7 +56,7 @@ export default {
'An error occurred while checking group path. Please refresh and try again.',
),
changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'),
- learnMore: s__('Groups|Learn more'),
+ learnMore: __('Learn more'),
},
inputSize: { md: 'lg' },
changingGroupPathHelpPagePath: helpPagePath('user/group/manage', {
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
index 79a2e11b0bb..982dab45117 100644
--- a/app/assets/javascripts/groups/components/overview_tabs.vue
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -176,7 +176,7 @@ export default {
:title="title"
:lazy="lazy"
>
- <groups-app :action="key" :service="service" :store="store" :hide-projects="false">
+ <groups-app :action="key" :service="service" :store="store">
<template v-if="emptyStateComponent" #empty-state>
<component :is="emptyStateComponent" />
</template>
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index f6711bde7d0..df2a23dc0f7 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -2,11 +2,11 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
+import GroupItemComponent from 'jh_else_ce/groups/components/group_item.vue';
import Translate from '../vue_shared/translate';
import GroupsApp from './components/app.vue';
import GroupFolderComponent from './components/group_folder.vue';
-import GroupItemComponent from './components/group_item.vue';
import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
import GroupFilterableList from './groups_filterable_list';
import GroupsService from './service/groups_service';
@@ -73,17 +73,15 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
},
data() {
const { dataset } = dataEl || this.$options.el;
- const hideProjects = parseBoolean(dataset.hideProjects);
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
const renderEmptyState = parseBoolean(dataset.renderEmptyState);
const service = new GroupsService(endpoint || dataset.endpoint);
- const store = new GroupsStore({ hideProjects, showSchemaMarkup });
+ const store = new GroupsStore({ hideProjects: true, showSchemaMarkup });
return {
action,
store,
service,
- hideProjects,
renderEmptyState,
loading: true,
containerId,
@@ -120,7 +118,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
action: this.action,
store: this.store,
service: this.service,
- hideProjects: this.hideProjects,
renderEmptyState: this.renderEmptyState,
containerId: this.containerId,
},
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index 4064520d1ca..ced5d76d8b9 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupItem from 'jh_else_ce/groups/components/group_item.vue';
import GroupFolder from './components/group_folder.vue';
-import GroupItem from './components/group_item.vue';
import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 422ec27346e..8c7612f37ff 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -215,7 +215,7 @@ export default {
<form
role="search"
:aria-label="$options.i18n.SEARCH_GITLAB"
- class="header-search gl-relative gl-rounded-base gl-w-full"
+ class="header-search-form gl-relative gl-rounded-base gl-w-full"
:class="searchBarClasses"
data-testid="header-search-form"
>
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index f6963263725..2bbad5f3f98 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -11,12 +11,12 @@ export const initHeaderSearchApp = (search = '') => {
const el = document.getElementById('js-header-search');
const headerEl = document.querySelector('.header-content');
- if (!el && !headerEl) {
+ if (!el || !headerEl) {
return false;
}
const searchContainer = headerEl.querySelector('.global-search-container');
- const newHeader = headerEl.querySelector('.header-search-new');
+ const newHeader = headerEl.querySelector('.header-search');
const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset;
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index 51af73decad..11a0095db92 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -42,6 +42,7 @@ export const initGitlabWebIDE = async (el) => {
editorFontSrcUrl,
editorFontFormat,
editorFontFamily,
+ codeSuggestionsEnabled,
} = el.dataset;
const rootEl = setupRootElement(el);
@@ -74,6 +75,7 @@ export const initGitlabWebIDE = async (el) => {
fontFamily: editorFontFamily,
format: editorFontFormat,
},
+ codeSuggestionsEnabled,
handleTracking,
async handleStartRemote({ remoteHost, remotePath, connectionToken }) {
const confirmed = await confirmAction(
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
index 9708e5e588c..bf0d3ed337c 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
@@ -18,10 +18,6 @@ export const templateTypes = () => [
name: __('Dockerfile'),
key: 'dockerfiles',
},
- {
- name: '.metrics-dashboard.yml',
- key: 'metrics_dashboard_ymls',
- },
];
export const showFileTemplatesBar = (_, getters, rootState) => (name) =>
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 246d27d3b94..fe50cb77eb8 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
@@ -560,7 +560,7 @@ export default {
gitlabLogo: window.gon.gitlab_logo,
PAGE_SIZES,
permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }),
- betaFeatureHelpPath: helpPagePath('policy/alpha-beta-support', { anchor: 'beta-features' }),
+ betaFeatureHelpPath: helpPagePath('policy/experiment-beta-support', { anchor: 'beta-features' }),
popoverOptions: { title: __('What is listed here?') },
i18n,
LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1',
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 6b5a828c009..e803e11bf6d 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -57,6 +57,8 @@ export const integrationTriggerEvents = {
PIPELINE: 'pipeline_events',
WIKI_PAGE: 'wiki_page_events',
DEPLOYMENT: 'deployment_events',
+ ALERT: 'alert_events',
+ INCIDENT: 'incident_events',
};
export const integrationTriggerEventTitles = {
@@ -82,6 +84,10 @@ export const integrationTriggerEventTitles = {
[integrationTriggerEvents.DEPLOYMENT]: s__(
'IntegrationEvents|A deployment is started or finished',
),
+ [integrationTriggerEvents.ALERT]: s__('IntegrationEvents|A new, unique alert is recorded'),
+ [integrationTriggerEvents.INCIDENT]: s__(
+ 'IntegrationEvents|An incident is created, closed, or reopened',
+ ),
};
export const billingPlans = {
@@ -104,4 +110,25 @@ export const placeholderForType = {
[INTEGRATION_TYPE_MATTERMOST]: __('my-channel'),
};
+export const INTEGRATION_FORM_TYPE_JIRA = 'jira';
export const INTEGRATION_FORM_TYPE_SLACK = 'gitlab_slack_application';
+
+export const jiraIntegrationAuthFields = {
+ AUTH_TYPE: 'jira_auth_type',
+ USERNAME: 'username',
+ PASSWORD: 'password',
+};
+export const jiraAuthTypeFieldProps = [
+ {
+ username: s__('JiraService|Email or username'),
+ password: s__('JiraService|API token or password'),
+ passwordHelp: s__(
+ 'JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server',
+ ),
+ nonEmptyPassword: s__('JiraService|New API token or password'),
+ },
+ {
+ password: s__('JiraService|Jira personal access token'),
+ nonEmptyPassword: s__('JiraService|New Jira personal access token'),
+ },
+];
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 904e5639cac..0a29906d5aa 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -97,7 +97,7 @@ export default {
return isEmpty(this.value) && this.required;
},
options() {
- return this.choices.map((choice) => {
+ return this.choices?.map((choice) => {
return {
value: choice[1],
text: choice[0],
diff --git a/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue
new file mode 100644
index 00000000000..30a39e48959
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_auth_fields.vue
@@ -0,0 +1,152 @@
+<script>
+import { mapGetters } from 'vuex';
+import { isEmpty } from 'lodash';
+import { GlFormGroup, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+import { jiraIntegrationAuthFields, jiraAuthTypeFieldProps } from '~/integrations/constants';
+import DynamicField from './dynamic_field.vue';
+
+const authTypeOptions = [
+ {
+ value: 0,
+ text: s__('JiraService|Basic'),
+ },
+ {
+ value: 1,
+ text: s__('JiraService|Jira personal access token'),
+ help: s__('JiraService|Recommended. Only available for Jira Data Center and Jira Server.'),
+ },
+];
+
+export default {
+ name: 'JiraAuthFields',
+
+ components: {
+ GlFormGroup,
+ GlFormRadio,
+ GlFormRadioGroup,
+ DynamicField,
+ },
+
+ props: {
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ fields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+
+ data() {
+ return {
+ authType: 0,
+ };
+ },
+
+ computed: {
+ ...mapGetters(['currentKey', 'isInheriting']),
+
+ isAuthTypeBasic() {
+ return this.authType === 0;
+ },
+
+ isNonEmptyPassword() {
+ return !isEmpty(this.passwordField?.value);
+ },
+
+ authTypeProps() {
+ return jiraAuthTypeFieldProps[this.authType];
+ },
+
+ authTypeField() {
+ return this.findFieldByName(jiraIntegrationAuthFields.AUTH_TYPE);
+ },
+
+ usernameField() {
+ return this.findFieldByName(jiraIntegrationAuthFields.USERNAME);
+ },
+
+ passwordField() {
+ return this.findFieldByName(jiraIntegrationAuthFields.PASSWORD);
+ },
+
+ usernameProps() {
+ return {
+ ...this.usernameField,
+ ...(this.isAuthTypeBasic ? { required: true } : {}),
+ title: this.authTypeProps.username,
+ };
+ },
+
+ passwordProps() {
+ const extraProps = this.isNonEmptyPassword
+ ? { title: this.authTypeProps.nonEmptyPassword }
+ : { title: this.authTypeProps.password, help: this.authTypeProps.passwordHelp };
+
+ return {
+ ...this.passwordField,
+ ...extraProps,
+ };
+ },
+ },
+
+ mounted() {
+ const authTypeValue = this.authTypeField?.value;
+ if (authTypeValue) {
+ this.authType = parseInt(authTypeValue, 10);
+ }
+ },
+
+ methods: {
+ findFieldByName(name) {
+ return this.fields.find((field) => field.name === name);
+ },
+ },
+
+ authTypeOptions,
+
+ i18n: {
+ authTypeLabel: __('Authentication method'),
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.authTypeLabel" label-for="service[jira_auth_type]">
+ <input name="service[jira_auth_type]" type="hidden" :value="authType" />
+ <gl-form-radio-group v-model="authType" :disabled="isInheriting">
+ <gl-form-radio
+ v-for="option in $options.authTypeOptions"
+ :key="option.value"
+ :value="option.value"
+ >
+ <template v-if="option.help" #help>
+ {{ option.help }}
+ </template>
+ {{ option.text }}
+ </gl-form-radio>
+ </gl-form-radio-group>
+
+ <div class="gl-ml-6 gl-mt-3">
+ <dynamic-field
+ v-if="isAuthTypeBasic"
+ :key="`${currentKey}-${usernameProps.name}`"
+ data-testid="jira-auth-username"
+ v-bind="usernameProps"
+ :is-validated="isValidated"
+ />
+ <dynamic-field
+ :key="`${currentKey}-${passwordProps.name}`"
+ data-testid="jira-auth-password"
+ v-bind="passwordProps"
+ :is-validated="isValidated"
+ />
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
index 63650400bb7..96ba276033c 100644
--- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -1,16 +1,16 @@
<script>
-import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
import { s__ } from '~/locale';
import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants';
const dropdownOptions = [
{
- value: false,
+ value: 'default',
text: s__('Integrations|Use default settings'),
},
{
- value: true,
+ value: 'custom',
text: s__('Integrations|Use custom settings'),
},
];
@@ -19,8 +19,7 @@ export default {
dropdownOptions,
name: 'OverrideDropdown',
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlLink,
},
props: {
@@ -39,8 +38,10 @@ export default {
},
},
data() {
+ const selectedValue = this.override ? 'custom' : 'default';
return {
- selected: dropdownOptions.find((x) => x.value === this.override),
+ selectedValue,
+ selectedOption: dropdownOptions.find((x) => x.value === selectedValue),
};
},
computed: {
@@ -54,9 +55,10 @@ export default {
},
},
methods: {
- onClick(option) {
- this.selected = option;
- this.$emit('change', option.value);
+ onSelect(value) {
+ this.selectedValue = value;
+ this.selectedOption = dropdownOptions.find((item) => item.value === value);
+ this.$emit('change', value === 'custom');
},
},
};
@@ -73,14 +75,11 @@ export default {
}}</gl-link>
</span>
<input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" />
- <gl-dropdown :text="selected.text">
- <gl-dropdown-item
- v-for="option in $options.dropdownOptions"
- :key="option.value"
- @click="onClick(option)"
- >
- {{ option.text }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ v-model="selectedValue"
+ :toggle-text="selectedOption.text"
+ :items="$options.dropdownOptions"
+ @select="onSelect"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/connection.vue b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
index 364e9324e43..6237f7983a6 100644
--- a/app/assets/javascripts/integrations/edit/components/sections/connection.vue
+++ b/app/assets/javascripts/integrations/edit/components/sections/connection.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import { INTEGRATION_FORM_TYPE_JIRA, jiraIntegrationAuthFields } from '~/integrations/constants';
import ActiveCheckbox from '../active_checkbox.vue';
import DynamicField from '../dynamic_field.vue';
@@ -9,6 +10,10 @@ export default {
components: {
ActiveCheckbox,
DynamicField,
+ JiraAuthFields: () =>
+ import(
+ /* webpackChunkName: 'integrationJiraAuthFields' */ '~/integrations/edit/components/jira_auth_fields.vue'
+ ),
},
props: {
fields: {
@@ -24,6 +29,29 @@ export default {
},
computed: {
...mapGetters(['currentKey', 'propsSource']),
+
+ isJiraIntegration() {
+ return this.propsSource.type === INTEGRATION_FORM_TYPE_JIRA;
+ },
+
+ filteredFields() {
+ if (!this.isJiraIntegration) {
+ return this.fields;
+ }
+
+ return this.fields.filter(
+ (field) => !Object.values(jiraIntegrationAuthFields).includes(field.name),
+ );
+ },
+ jiraAuthFields() {
+ if (!this.isJiraIntegration) {
+ return [];
+ }
+
+ return this.fields.filter((field) =>
+ Object.values(jiraIntegrationAuthFields).includes(field.name),
+ );
+ },
},
};
</script>
@@ -36,10 +64,16 @@ export default {
@toggle-integration-active="$emit('toggle-integration-active', $event)"
/>
<dynamic-field
- v-for="field in fields"
+ v-for="field in filteredFields"
:key="`${currentKey}-${field.name}`"
v-bind="field"
:is-validated="isValidated"
/>
+ <jira-auth-fields
+ v-if="isJiraIntegration"
+ :key="`${currentKey}-jira-auth-fields`"
+ :is-validated="isValidated"
+ :fields="jiraAuthFields"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/api.js b/app/assets/javascripts/integrations/gitlab_slack_application/api.js
new file mode 100644
index 00000000000..9a4887f70f5
--- /dev/null
+++ b/app/assets/javascripts/integrations/gitlab_slack_application/api.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+export const addProjectToSlack = (url, projectId) => {
+ return axios.get(url, {
+ params: { project_id: projectId },
+ });
+};
diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue
new file mode 100644
index 00000000000..bcb199853bd
--- /dev/null
+++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/gitlab_slack_application.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+import { i18n } from '../constants';
+
+import { addProjectToSlack } from '../api';
+import ProjectsDropdown from './projects_dropdown.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ GlLink,
+ ProjectsDropdown,
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isSignedIn: {
+ type: Boolean,
+ required: true,
+ },
+ signInPath: {
+ type: String,
+ required: true,
+ },
+ slackLinkPath: {
+ type: String,
+ required: true,
+ },
+ gitlabLogoPath: {
+ type: String,
+ required: true,
+ },
+ slackLogoPath: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n,
+ learnMoreLink: helpPagePath('user/project/integrations/gitlab_slack_application', {
+ anchor: 'configuration',
+ }),
+ data() {
+ return {
+ selectedProject: null,
+ };
+ },
+ computed: {
+ hasProjects() {
+ return this.projects.length > 0;
+ },
+ },
+ methods: {
+ selectProject(project) {
+ this.selectedProject = project;
+ },
+ addToSlack() {
+ addProjectToSlack(this.slackLinkPath, this.selectedProject.id)
+ .then((response) => redirectTo(response.data.add_to_slack_link)) // eslint-disable-line import/no-deprecated
+ .catch(() =>
+ createAlert({
+ message: i18n.slackErrorMessage,
+ }),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-max-w-max-content gl-mx-auto gl-mt-11 gl-text-center">
+ <div v-once class="gl-my-5 gl-display-flex gl-justify-content-center gl-align-items-center">
+ <img :src="gitlabLogoPath" :alt="$options.i18n.gitlabLogoAlt" class="gl-h-11 gl-w-11" />
+ <gl-icon name="arrow-right" :size="32" class="gl-mx-5 gl-text-gray-200" />
+ <img
+ :src="slackLogoPath"
+ :alt="$options.i18n.slackLogoAlt"
+ class="gitlab-slack-slack-logo gl-h-11 gl-w-11"
+ />
+ </div>
+
+ <h2>{{ $options.i18n.title }}</h2>
+
+ <div data-testid="gitlab-slack-content">
+ <template v-if="isSignedIn">
+ <div v-if="hasProjects" class="gl-mt-6">
+ <p>
+ {{ $options.i18n.dropdownLabel }}
+ </p>
+
+ <projects-dropdown
+ :projects="projects"
+ :selected-project="selectedProject"
+ @project-selected="selectProject"
+ />
+
+ <div class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :disabled="!selectedProject"
+ @click="addToSlack"
+ >
+ {{ $options.i18n.dropdownButtonText }}
+ </gl-button>
+ </div>
+ </div>
+ <div v-else>
+ <p class="gl-mb-0">{{ $options.i18n.noProjects }}</p>
+ <p>
+ <span>{{ $options.i18n.noProjectsDescription }}</span>
+ <gl-link :href="$options.learnMoreLink" target="_blank">{{
+ $options.i18n.learnMore
+ }}</gl-link
+ >.
+ </p>
+ </div>
+ </template>
+
+ <template v-else>
+ <p>{{ $options.i18n.signInLabel }}</p>
+ <gl-button category="primary" variant="confirm" :href="signInPath">
+ {{ $options.i18n.signInButtonText }}
+ </gl-button>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue
new file mode 100644
index 00000000000..26d191cd0bf
--- /dev/null
+++ b/app/assets/javascripts/integrations/gitlab_slack_application/components/projects_dropdown.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlDropdown } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
+
+export default {
+ components: {
+ GlDropdown,
+ ProjectListItem,
+ },
+ props: {
+ projectDropdownText: {
+ type: String,
+ required: false,
+ default: __('Select a project'),
+ },
+ projects: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ dropdownText() {
+ return this.selectedProject
+ ? this.selectedProject.name_with_namespace
+ : this.projectDropdownText;
+ },
+ },
+ methods: {
+ onClick(project) {
+ this.$emit('project-selected', project);
+ this.$refs.dropdown.hide(true);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown ref="dropdown" block :text="dropdownText" menu-class="gl-w-full!">
+ <project-list-item
+ v-for="project in projects"
+ :key="project.id"
+ :project="project"
+ :selected="false"
+ @click="onClick(project)"
+ />
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/constants.js b/app/assets/javascripts/integrations/gitlab_slack_application/constants.js
new file mode 100644
index 00000000000..4f3c75b64fb
--- /dev/null
+++ b/app/assets/javascripts/integrations/gitlab_slack_application/constants.js
@@ -0,0 +1,15 @@
+import { __, s__ } from '~/locale';
+
+export const i18n = {
+ slackErrorMessage: __('Unable to build Slack link.'),
+ gitlabLogoAlt: __('GitLab logo'),
+ slackLogoAlt: __('Slack logo'),
+ title: s__('SlackIntegration|GitLab for Slack'),
+ dropdownLabel: s__('SlackIntegration|Select a GitLab project to link with your Slack workspace.'),
+ dropdownButtonText: __('Continue'),
+ noProjects: __('No projects available.'),
+ noProjectsDescription: __('Make sure you have the correct permissions to link your project.'),
+ learnMore: __('Learn more'),
+ signInLabel: s__('JiraService|Sign in to GitLab to get started.'),
+ signInButtonText: __('Sign in to GitLab'),
+};
diff --git a/app/assets/javascripts/integrations/gitlab_slack_application/index.js b/app/assets/javascripts/integrations/gitlab_slack_application/index.js
new file mode 100644
index 00000000000..8bbb81df997
--- /dev/null
+++ b/app/assets/javascripts/integrations/gitlab_slack_application/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GitlabSlackApplication from './components/gitlab_slack_application.vue';
+
+export default () => {
+ const el = document.querySelector('.js-gitlab-slack-application');
+
+ if (!el) return null;
+
+ const {
+ projects,
+ isSignedIn,
+ signInPath,
+ slackLinkPath,
+ gitlabLogoPath,
+ slackLogoPath,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(GitlabSlackApplication, {
+ props: {
+ projects: JSON.parse(projects),
+ isSignedIn: parseBoolean(isSignedIn),
+ signInPath,
+ slackLinkPath,
+ gitlabLogoPath,
+ slackLogoPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index 10c08d63612..cc95027f0db 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,15 +1,24 @@
<script>
import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
-import { uniqueId } from 'lodash';
+import { uniqueId, isEmpty } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import eventHub from '../event_hub';
+
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
} from '../utils/trigger_successful_invite_alert';
-import { PROJECT_SELECT_LABEL_ID } from '../constants';
+
+import {
+ PROJECT_SELECT_LABEL_ID,
+ IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
+ IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+} from '../constants';
+
+import UserLimitNotification from './user_limit_notification.vue';
import ProjectSelect from './project_select.vue';
export default {
@@ -18,8 +27,15 @@ export default {
GlFormGroup,
GlModal,
GlSprintf,
+ UserLimitNotification,
ProjectSelect,
},
+ mixins: [
+ Tracking.mixin({
+ category: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
+ label: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+ }),
+ ],
props: {
projectId: {
type: String,
@@ -34,6 +50,11 @@ export default {
required: false,
default: false,
},
+ usersLimitDataset: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -54,6 +75,12 @@ export default {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
+ showUserLimitNotification() {
+ return !isEmpty(this.usersLimitDataset.alertVariant);
+ },
+ limitVariant() {
+ return this.usersLimitDataset.alertVariant;
+ },
actionPrimary() {
return {
text: this.$options.i18n.modalPrimaryButton,
@@ -79,6 +106,7 @@ export default {
},
methods: {
openModal() {
+ this.track('render');
this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
},
closeModal() {
@@ -102,6 +130,8 @@ export default {
});
},
onInviteSuccess() {
+ this.track('invite_successful');
+
if (this.reloadPageOnSubmit) {
reloadOnInvitationSuccess();
} else {
@@ -115,6 +145,12 @@ export default {
showErrorAlert() {
this.invalidFeedbackMessage = this.$options.i18n.defaultError;
},
+ onCancel() {
+ this.track('click_cancel');
+ },
+ onClose() {
+ this.track('click_x');
+ },
},
toastOptions() {
return {
@@ -153,7 +189,15 @@ export default {
no-focus-on-show
@primary="submitImport"
@hidden="resetFields"
+ @cancel="onCancel"
+ @close="onClose"
>
+ <user-limit-notification
+ v-if="showUserLimitNotification"
+ class="gl-mb-5"
+ :limit-variant="limitVariant"
+ :users-limit-dataset="usersLimitDataset"
+ />
<p ref="modalIntro">
<gl-sprintf :message="modalIntro">
<template #strong="{ content }">
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 68602068699..e0bfa1111e8 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -114,11 +114,11 @@ export default {
},
},
methods: {
- handleTextInput(query) {
+ handleTextInput(inputQuery) {
this.hideDropdownWithNoItems = false;
- this.query = query;
+ this.query = inputQuery.trim();
this.loading = true;
- this.retrieveUsers(query);
+ this.retrieveUsers();
},
updateTokenClasses() {
this.selectedTokens = this.selectedTokens.map((token) => ({
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 9afcaff6e16..a4fe1a413aa 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -25,6 +25,8 @@ export const TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI = 'dropdown-text-emoji';
export const TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN = 'dropdown-text';
export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal';
export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button';
+export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY = 'invite_project_members_modal';
+export const IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL = 'project-members-page';
export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
export const MEMBERS_MODAL_CELEBRATE_TITLE = s__(
'InviteMembersModal|GitLab is better with colleagues!',
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
index 227d8395250..90479038414 100644
--- a/app/assets/javascripts/invite_members/init_import_project_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default function initImportProjectMembersModal() {
const el = document.querySelector('.js-import-project-members-modal');
@@ -9,16 +9,20 @@ export default function initImportProjectMembersModal() {
return false;
}
- const { projectId, projectName, reloadPageOnSubmit } = el.dataset;
+ const { projectId, projectName, reloadPageOnSubmit, usersLimitDataset } = el.dataset;
return new Vue({
el,
+ provide: {
+ name: projectName,
+ },
render: (createElement) =>
createElement(ImportProjectMembersModal, {
props: {
projectId,
projectName,
reloadPageOnSubmit: parseBoolean(reloadPageOnSubmit),
+ usersLimitDataset: convertObjectPropsToCamelCase(JSON.parse(usersLimitDataset || '{}')),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index b492194d1cf..872e1d4269d 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -1,18 +1,13 @@
<script>
-import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
export default {
- i18n: {
- exportAsCsvButtonText: __('Export as CSV'),
- importCsvText: __('Import CSV'),
- importFromJiraText: __('Import from Jira'),
- },
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
CsvExportModal,
CsvImportModal,
},
@@ -48,6 +43,22 @@ export default {
default: undefined,
},
},
+ data() {
+ return {
+ dropdownItems: {
+ exportAsCSV: {
+ text: __('Export as CSV'),
+ },
+ importCSV: {
+ text: __('Import CSV'),
+ },
+ importFromJIRA: {
+ text: __('Import from Jira'),
+ href: this.projectImportJiraPath,
+ },
+ },
+ };
+ },
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
@@ -61,23 +72,25 @@ export default {
<template>
<ul class="gl-display-contents">
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
v-if="showExportButton"
v-gl-modal="exportModalId"
+ data-testid="export-as-csv-button"
data-qa-selector="export_as_csv_button"
- >
- {{ $options.i18n.exportAsCsvButtonText }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="showImportButton" v-gl-modal="importModalId">
- {{ $options.i18n.importCsvText }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ :item="dropdownItems.exportAsCSV"
+ />
+ <gl-disclosure-dropdown-item
+ v-if="showImportButton"
+ v-gl-modal="importModalId"
+ data-testid="import-from-csv-button"
+ :item="dropdownItems.importCSV"
+ />
+ <gl-disclosure-dropdown-item
v-if="showImportButton && canEdit"
- :href="projectImportJiraPath"
+ data-testid="import-from-jira-link"
data-qa-selector="import_from_jira_link"
- >
- {{ $options.i18n.importFromJiraText }}
- </gl-dropdown-item>
+ :item="dropdownItems.importFromJIRA"
+ />
<csv-export-modal
v-if="showExportButton"
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 403997779ac..eab7d01be14 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -40,6 +40,9 @@ export default {
iconName: 'lock',
visible: this.isLocked,
dataTestId: 'locked',
+ tooltip: sprintf(__('This %{issuable} is locked. Only project members can comment.'), {
+ issuable: noteableTypeText[this.getNoteableData.targetType],
+ }),
},
{
iconName: 'spam',
@@ -67,7 +70,7 @@ export default {
<div
v-if="meta.visible"
:key="meta.iconName"
- v-gl-tooltip
+ v-gl-tooltip.bottom
:data-testid="meta.dataTestId"
:title="meta.tooltip || null"
:class="{
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index df50a30abb7..ff48bfceb29 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -248,7 +248,7 @@ export default {
size="small"
:disabled="removeDisabled"
class="js-issue-item-remove-button gl-mr-2"
- data-qa-selector="remove_related_issue_button"
+ data-testid="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
@click="onRemoveRequest"
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index c79612ad5d0..444ee704521 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -14,6 +14,7 @@ export const TYPE_EPIC = 'epic';
export const TYPE_INCIDENT = 'incident';
export const TYPE_ISSUE = 'issue';
export const TYPE_MERGE_REQUEST = 'merge_request';
+export const TYPE_MILESTONE = 'milestone';
export const TYPE_TEST_CASE = 'test_case';
export const WORKSPACE_GROUP = 'group';
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index b9e4d0df3f2..14fe88b8f61 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -29,6 +29,7 @@ import {
getSortOptions,
isSortKey,
} from '~/issues/list/utils';
+import { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
@@ -126,6 +127,10 @@ export default {
update(data) {
return data.issues.nodes ?? [];
},
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ // We need this for handling loading state when using frontend cache
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details
+ notifyOnNetworkStatusChange: true,
result({ data }) {
this.pageInfo = data?.issues.pageInfo ?? {};
},
@@ -183,6 +188,17 @@ export default {
hasSearch() {
return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length);
},
+ // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache.
+ // if we have cached data, we disregard the loading state
+ isLoading() {
+ return (
+ this.$apollo.queries.issues.loading &&
+ !this.$apollo.provider.clients.defaultClient.readQuery({
+ query: getIssuesQuery,
+ variables: this.queryVariables,
+ })
+ );
+ },
queryVariables() {
return {
hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
@@ -446,7 +462,7 @@ export default {
:initial-filter-value="filterTokens"
:initial-sort-by="sortKey"
:issuables="renderedIssues"
- :issuables-loading="$apollo.queries.issues.loading"
+ :issuables-loading="isLoading"
namespace="dashboard"
recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder"
@@ -494,6 +510,7 @@ export default {
<gl-empty-state
:description="emptyStateDescription"
:svg-path="emptyStateSvgPath"
+ :svg-height="150"
:title="emptyStateTitle"
/>
</template>
diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js
index 005ab5ce3b0..999f07781b2 100644
--- a/app/assets/javascripts/issues/dashboard/index.js
+++ b/app/assets/javascripts/issues/dashboard/index.js
@@ -1,10 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
+import { gqlClient } from '~/issues/list/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuesDashboardApp from './components/issues_dashboard_app.vue';
-export function mountIssuesDashboardApp() {
+export async function mountIssuesDashboardApp() {
const el = document.querySelector('.js-issues-dashboard');
if (!el) {
@@ -34,7 +34,7 @@ export function mountIssuesDashboardApp() {
el,
name: 'IssuesDashboardRoot',
apolloProvider: new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: await gqlClient(),
}),
provide: {
autocompleteAwardEmojisPath,
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
index 5625e6afad3..5c331fe95e2 100644
--- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "./issue.fragment.graphql"
+#import "~/issues/list/queries/issue.fragment.graphql"
query getDashboardIssues(
$hideUsers: Boolean = false
@@ -44,8 +44,9 @@ query getDashboardIssues(
before: $beforeCursor
first: $firstPageSize
last: $lastPageSize
- ) {
+ ) @persist {
nodes {
+ __persist
...IssueFragment
reference(full: true)
}
diff --git a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql
deleted file mode 100644
index 040763f2ba4..00000000000
--- a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql
+++ /dev/null
@@ -1,56 +0,0 @@
-fragment IssueFragment on Issue {
- id
- iid
- confidential
- createdAt
- downvotes
- dueDate
- hidden
- humanTimeEstimate
- mergeRequestsCount
- moved
- state
- title
- updatedAt
- closedAt
- upvotes
- userDiscussionsCount @include(if: $isSignedIn)
- webPath
- webUrl
- type
- assignees @skip(if: $hideUsers) {
- nodes {
- id
- avatarUrl
- name
- username
- webUrl
- }
- }
- author @skip(if: $hideUsers) {
- id
- avatarUrl
- name
- username
- webUrl
- }
- labels {
- nodes {
- id
- color
- title
- description
- }
- }
- milestone {
- id
- dueDate
- startDate
- webPath
- title
- }
- taskCompletionStatus {
- completedCount
- count
- }
-}
diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue
index 8aece24de0c..3c58843bcbc 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue
@@ -28,6 +28,7 @@ export default {
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
+ :svg-height="150"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
@@ -49,5 +50,10 @@ export default {
</template>
</gl-empty-state>
- <gl-empty-state v-else :title="$options.i18n.noClosedIssuesTitle" :svg-path="emptyStateSvgPath" />
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noClosedIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ :svg-height="150"
+ />
</template>
diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 98429f3ffd1..3f29fc66abb 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlDisclosureDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
@@ -12,7 +12,7 @@ export default {
components: {
CsvImportExportButtons,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlEmptyState,
GlLink,
GlSprintf,
@@ -56,7 +56,11 @@ export default {
<template>
<div v-if="isSignedIn">
- <gl-empty-state :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath">
+ <gl-empty-state
+ :title="$options.i18n.noIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ :svg-height="150"
+ >
<template #description>
<gl-link :href="$options.issuesHelpPagePath">
{{ $options.i18n.noIssuesDescription }}
@@ -73,17 +77,17 @@ export default {
{{ $options.i18n.newIssueLabel }}
</gl-button>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="showCsvButtons"
class="gl-w-full gl-sm-w-auto gl-sm-mr-3"
- :text="$options.i18n.importIssues"
+ :toggle-text="$options.i18n.importIssues"
data-qa-selector="import_issues_dropdown"
>
<csv-import-export-buttons
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
- </gl-dropdown>
+ </gl-disclosure-dropdown>
<new-resource-dropdown
v-if="showNewIssueDropdown"
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 5fb83dfd1ab..83b0bcebe67 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -1,11 +1,11 @@
<script>
import {
GlButton,
+ GlButtonGroup,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
GlFilteredSearchToken,
GlTooltipDirective,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
@@ -14,6 +14,7 @@ import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_st
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
@@ -68,6 +69,9 @@ import {
defaultWorkItemTypes,
i18n,
ISSUE_REFERENCE,
+ ISSUES_GRID_VIEW_KEY,
+ ISSUES_LIST_VIEW_KEY,
+ ISSUES_VIEW_TYPE_KEY,
MAX_LIST_SIZE,
PARAM_FIRST_PAGE_SIZE,
PARAM_LAST_PAGE_SIZE,
@@ -116,19 +120,23 @@ const CrmOrganizationToken = () =>
export default {
i18n,
issuableListTabs,
+ ISSUES_VIEW_TYPE_KEY,
+ ISSUES_GRID_VIEW_KEY,
+ ISSUES_LIST_VIEW_KEY,
components: {
CsvImportExportButtons,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
EmptyStateWithAnyIssues,
EmptyStateWithoutAnyIssues,
GlButton,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
+ GlButtonGroup,
IssuableByEmail,
IssuableList,
IssueCardStatistics,
IssueCardTimeInfo,
NewResourceDropdown,
+ LocalStorageSync,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -194,6 +202,21 @@ export default {
sortKey: CREATED_DESC,
state: STATUS_OPEN,
pageSize: DEFAULT_PAGE_SIZE,
+ viewType: ISSUES_LIST_VIEW_KEY,
+ subscribeDropdownOptions: {
+ items: [
+ {
+ text: i18n.rssLabel,
+ href: this.rssPath,
+ extraAttrs: { 'data-testid': 'subscribe-rss' },
+ },
+ {
+ text: i18n.calendarLabel,
+ href: this.calendarPath,
+ extraAttrs: { 'data-testid': 'subscribe-calendar' },
+ },
+ ],
+ },
};
},
apollo: {
@@ -504,6 +527,12 @@ export default {
})
);
},
+ gridViewFeatureEnabled() {
+ return Boolean(this.glFeatures?.issuesGridView);
+ },
+ isGridView() {
+ return this.viewType === ISSUES_GRID_VIEW_KEY;
+ },
},
watch: {
$route(newValue, oldValue) {
@@ -764,6 +793,15 @@ export default {
this.sortKey = sortKey;
this.state = state || STATUS_OPEN;
},
+ switchViewType(type) {
+ // Filter the wrong data from localStorage
+ if (type === ISSUES_GRID_VIEW_KEY) {
+ this.viewType = ISSUES_GRID_VIEW_KEY;
+ return;
+ }
+ // The default view is list view
+ this.viewType = ISSUES_LIST_VIEW_KEY;
+ },
},
};
</script>
@@ -798,6 +836,7 @@ export default {
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
:show-filtered-search-friendly-text="hasOrFeature"
+ :is-grid-view="isGridView"
show-work-item-type-icon
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@@ -810,6 +849,30 @@ export default {
@page-size-change="handlePageSizeChange"
>
<template #nav-actions>
+ <local-storage-sync
+ v-if="gridViewFeatureEnabled"
+ :value="viewType"
+ :storage-key="$options.ISSUES_VIEW_TYPE_KEY"
+ @input="switchViewType"
+ >
+ <gl-button-group>
+ <gl-button
+ :variant="isGridView ? 'default' : 'confirm'"
+ data-testid="list-view-type"
+ @click="switchViewType($options.ISSUES_LIST_VIEW_KEY)"
+ >
+ {{ $options.i18n.listLabel }}
+ </gl-button>
+ <gl-button
+ :variant="isGridView ? 'confirm' : 'default'"
+ data-testid="grid-view-type"
+ @click="switchViewType($options.ISSUES_GRID_VIEW_KEY)"
+ >
+ {{ $options.i18n.gridLabel }}
+ </gl-button>
+ </gl-button-group>
+ </local-storage-sync>
+
<gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
@@ -831,12 +894,12 @@ export default {
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-gl-tooltip.hover="$options.i18n.actionsLabel"
category="tertiary"
icon="ellipsis_v"
no-caret
- :text="$options.i18n.actionsLabel"
+ :toggle-text="$options.i18n.actionsLabel"
text-sr-only
data-qa-selector="issues_list_more_actions_dropdown"
>
@@ -845,16 +908,8 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
-
- <gl-dropdown-divider v-if="showCsvButtons" />
-
- <gl-dropdown-item :href="rssPath">
- {{ $options.i18n.rssLabel }}
- </gl-dropdown-item>
- <gl-dropdown-item :href="calendarPath">
- {{ $options.i18n.calendarLabel }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-group :bordered="true" :group="subscribeDropdownOptions" />
+ </gl-disclosure-dropdown>
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 56d3a57457b..1a3d97277c7 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -75,6 +75,10 @@ export const NORMAL_FILTER = 'normalFilter';
export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
+export const ISSUES_VIEW_TYPE_KEY = 'issuesViewType';
+export const ISSUES_LIST_VIEW_KEY = 'List';
+export const ISSUES_GRID_VIEW_KEY = 'Grid';
+
export const i18n = {
actionsLabel: __('Actions'),
calendarLabel: __('Subscribe to calendar'),
@@ -116,6 +120,8 @@ export const i18n = {
upvotes: __('Upvotes'),
titles: __('Titles'),
descriptions: __('Descriptions'),
+ listLabel: __('List'),
+ gridLabel: __('Grid'),
};
export const urlSortParams = {
diff --git a/app/assets/javascripts/issues/list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
index 46b48e4e41c..6a1967a8875 100644
--- a/app/assets/javascripts/issues/list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
@@ -14,7 +14,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false)
}
project(fullPath: $fullPath) @include(if: $isProject) {
id
- projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
+ projectMembers(
+ search: $search
+ relations: [DIRECT, INHERITED, INVITED_GROUPS, SHARED_INTO_ANCESTORS]
+ ) {
nodes {
id
user {
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index 86311b99f7c..fcdf1f7741b 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -188,6 +188,11 @@ export default {
required: false,
default: null,
},
+ issueIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
const store = new Store({
@@ -446,7 +451,10 @@ export default {
},
showStickyHeader() {
- this.isStickyHeaderShowing = true;
+ // only if scrolled under the issue's title
+ if (this.$refs.title.$el.offsetTop < window.pageYOffset) {
+ this.isStickyHeaderShowing = true;
+ }
},
handleSaveDescription(description) {
@@ -496,6 +504,7 @@ export default {
</div>
<div v-else>
<title-component
+ ref="title"
:issuable-ref="issuableRef"
:can-update="canUpdate"
:title-html="state.titleHtml"
@@ -522,7 +531,13 @@ export default {
statusText
}}</span></gl-badge
>
- <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon">
+ <span
+ v-if="isLocked"
+ v-gl-tooltip.bottom
+ data-testid="locked"
+ class="issuable-warning-icon"
+ :title="__('This issue is locked. Only project members can comment.')"
+ >
<gl-icon name="lock" :aria-label="__('Locked')" />
</span>
<confidentiality-badge
@@ -533,19 +548,20 @@ export default {
/>
<span
v-if="isHidden"
- v-gl-tooltip
+ v-gl-tooltip.bottom
:title="__('This issue is hidden because its author has been banned')"
data-testid="hidden"
class="issuable-warning-icon"
>
<gl-icon name="spam" />
</span>
- <p
- class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
+ <a
+ href="#top"
+ class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-text-black-normal"
:title="state.titleText"
>
{{ state.titleText }}
- </p>
+ </a>
</div>
</div>
</transition>
@@ -560,6 +576,7 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
+ :issue-iid="issueIid"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 3721f224d5e..3bf4dfc7a99 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -11,8 +11,7 @@ import { TYPE_ISSUE } from '~/issues/constants';
import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
-import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
-import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
+import { addHierarchyChild, removeHierarchyChild } from '~/work_items/graphql/cache_utils';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
@@ -78,6 +77,11 @@ export default {
required: false,
default: null,
},
+ issueIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
isUpdating: {
type: Boolean,
required: false,
@@ -330,29 +334,33 @@ export default {
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
+
const iterationInput = {
iterationWidget: {
iterationId: this.issueDetails.iteration?.id ?? null,
},
};
- const input = {
- confidential: this.issueDetails.confidential,
- description,
- hierarchyWidget: {
- parentId: this.issueGid,
- },
- ...(this.hasIterationsFeature && iterationInput),
- milestoneWidget: {
- milestoneId: this.issueDetails.milestone?.id ?? null,
- },
- projectPath: this.fullPath,
- title,
- workItemTypeId: this.taskWorkItemTypeId,
- };
const { data } = await this.$apollo.mutate({
mutation: createWorkItemMutation,
- variables: { input },
+ variables: {
+ input: {
+ confidential: this.issueDetails.confidential,
+ description,
+ hierarchyWidget: {
+ parentId: this.issueGid,
+ },
+ ...(this.hasIterationsFeature && iterationInput),
+ milestoneWidget: {
+ milestoneId: this.issueDetails.milestone?.id ?? null,
+ },
+ projectPath: this.fullPath,
+ title,
+ workItemTypeId: this.taskWorkItemTypeId,
+ },
+ },
+ update: (cache, { data: { workItemCreate } }) =>
+ addHierarchyChild(cache, this.fullPath, String(this.issueIid), workItemCreate.workItem),
});
const { workItem, errors } = data.workItemCreate;
@@ -361,11 +369,6 @@ export default {
throw new Error(errors);
}
- await this.$apollo.mutate({
- mutation: addHierarchyChildMutation,
- variables: { id: this.issueGid, workItem },
- });
-
this.$toast.show(s__('WorkItem|Converted to task'), {
action: {
text: s__('WorkItem|Undo'),
@@ -386,19 +389,14 @@ export default {
const { data } = await this.$apollo.mutate({
mutation: deleteWorkItemMutation,
variables: { input: { id } },
+ update: (cache) =>
+ removeHierarchyChild(cache, this.fullPath, String(this.issueIid), { id }),
});
- const { errors } = data.workItemDelete;
-
- if (errors?.length) {
- throw new Error(errors);
+ if (data.workItemDelete.errors?.length) {
+ throw new Error(data.workItemDelete.errors);
}
- await this.$apollo.mutate({
- mutation: removeHierarchyChildMutation,
- variables: { id: this.issueGid, workItem: { id } },
- });
-
this.$toast.show(s__('WorkItem|Task reverted'));
} catch (error) {
this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error);
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 775f25bdbc0..576d157e0fc 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -71,7 +71,7 @@ export default {
:label="$options.i18n.label"
label-class="sr-only"
label-for="issuable-type"
- class="mb-2 mb-md-0"
+ class="gl-mb-0"
>
<gl-collapsible-listbox
v-model="selectedIssueType"
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index 2e99c03d250..c9e21b296e4 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -222,7 +222,7 @@ export default {
<convert-description-modal
v-if="issueId && glFeatures.generateDescriptionAi"
- class="gl-pl-5 gl-sm-pl-0"
+ class="gl-pl-5 gl-md-pl-0"
:resource-id="resourceId"
:user-id="userId"
@contentGenerated="setDescription"
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 4d9b69ddf99..a36b0c46927 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -20,7 +20,7 @@ import {
NEW_ACTIONS_POPOVER_KEY,
} from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
+import { getCookie, parseBoolean, setCookie, isLoggedIn } from '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
@@ -137,6 +137,7 @@ export default {
data() {
return {
isReportAbuseDrawerOpen: false,
+ isUserSignedIn: isLoggedIn(),
};
},
apollo: {
@@ -204,7 +205,11 @@ export default {
},
hasDesktopDropdown() {
return (
- this.canCreateIssue || this.canPromoteToEpic || !this.isIssueAuthor || this.canReportSpam
+ this.canCreateIssue ||
+ this.canPromoteToEpic ||
+ !this.isIssueAuthor ||
+ this.canReportSpam ||
+ this.issuableReference
);
},
hasMobileDropdown() {
@@ -219,7 +224,10 @@ export default {
return this.glFeatures.movedMrSidebar;
},
showLockIssueOption() {
- return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE;
+ return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE && this.isUserSignedIn;
+ },
+ showMovedSidebarOptions() {
+ return this.isMrSidebarMoved && this.isUserSignedIn;
},
},
created() {
@@ -326,17 +334,16 @@ export default {
</script>
<template>
- <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3">
+ <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-sm-gap-3">
<gl-dropdown
v-if="hasMobileDropdown"
class="gl-sm-display-none! w-100"
block
:text="dropdownText"
- data-qa-selector="issue_actions_dropdown"
data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
- <template v-if="isMrSidebarMoved">
+ <template v-if="showMovedSidebarOptions">
<sidebar-subscriptions-widget
:iid="String(iid)"
:full-path="fullPath"
@@ -356,7 +363,7 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="showToggleIssueStateButton"
- :data-qa-selector="`mobile_${qaSelector}`"
+ :data-testid="`mobile_${qaSelector}`"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -375,7 +382,7 @@ export default {
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
>
<gl-dropdown-item
- v-if="issuableEmailAddress"
+ v-if="issuableEmailAddress && showMovedSidebarOptions"
:data-clipboard-text="issuableEmailAddress"
data-testid="copy-email"
@click="copyEmailAddress"
@@ -401,7 +408,7 @@ export default {
</gl-dropdown-item>
</template>
<gl-dropdown-item
- v-if="!isIssueAuthor"
+ v-if="!isIssueAuthor && isUserSignedIn"
data-testid="report-abuse-item"
@click="toggleReportAbuseDrawer(true)"
>
@@ -426,7 +433,7 @@ export default {
class="gl-display-none gl-sm-display-inline-flex!"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
- data-testid="toggle-button"
+ data-testid="toggle-issue-state-button"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -439,7 +446,6 @@ export default {
class="gl-display-none gl-sm-display-inline-flex!"
icon="ellipsis_v"
category="tertiary"
- data-qa-selector="issue_actions_ellipsis_dropdown"
:text="dropdownText"
:text-sr-only="true"
:title="dropdownText"
@@ -449,7 +455,7 @@ export default {
right
@shown="dismissPopover"
>
- <template v-if="isMrSidebarMoved">
+ <template v-if="showMovedSidebarOptions">
<sidebar-subscriptions-widget
:iid="String(iid)"
:full-path="fullPath"
@@ -460,7 +466,7 @@ export default {
<gl-dropdown-divider />
</template>
- <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
+ <gl-dropdown-item v-if="canCreateIssue && isUserSignedIn" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
<gl-dropdown-item
@@ -482,7 +488,7 @@ export default {
>{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
>
<gl-dropdown-item
- v-if="issuableEmailAddress"
+ v-if="issuableEmailAddress && showMovedSidebarOptions"
:data-clipboard-text="issuableEmailAddress"
data-testid="copy-email"
@click="copyEmailAddress"
@@ -502,14 +508,14 @@ export default {
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
- data-qa-selector="delete_issue_button"
+ data-testid="delete_issue_button"
@click="track('click_dropdown')"
>
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
<gl-dropdown-item
- v-if="!isIssueAuthor"
+ v-if="!isIssueAuthor && isUserSignedIn"
data-testid="report-abuse-item"
@click="toggleReportAbuseDrawer(true)"
>
diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
index 5160903c762..64b916caddb 100644
--- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
+++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
@@ -17,14 +17,9 @@ export default {
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
- this.closeDropdown();
},
deleteTaskListItem() {
eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
- this.closeDropdown();
- },
- closeDropdown() {
- this.$refs.dropdown.close();
},
},
};
@@ -33,7 +28,6 @@ export default {
<template>
<gl-disclosure-dropdown
v-if="canUpdate"
- ref="dropdown"
class="task-list-item-actions-wrapper"
category="tertiary"
icon="ellipsis_v"
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 2d2ef327018..c464f48d574 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -60,7 +60,6 @@ export default {
'issue-realtime-trigger-pulse': pulseAnimation,
}"
class="title gl-font-size-h-display"
- data-qa-selector="title_content"
data-testid="issue-title"
dir="auto"
></h1>
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 5a51ac18446..bc4284457f6 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -133,6 +133,7 @@ export function initIssueApp(issueData, store) {
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
issueId: this.getNoteableData?.id,
+ issueIid: this.getNoteableData?.iid,
},
});
},
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
index 0b286bc903f..58b15b3eed1 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_button.vue
@@ -14,10 +14,11 @@ export default {
ADD_NAMESPACE_MODAL_ID,
};
</script>
+
<template>
<div>
<gl-button v-gl-modal="$options.ADD_NAMESPACE_MODAL_ID" category="primary" variant="info">
- {{ s__('Integrations|Add namespace') }}
+ {{ s__('JiraConnect|Link groups') }}
</gl-button>
<add-namespace-modal />
</div>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue
index 0e209a09b16..b2700c660b1 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/add_namespace_modal.vue
@@ -8,13 +8,14 @@ export default {
components: { GlModal, GroupsList },
modal: {
id: ADD_NAMESPACE_MODAL_ID,
- title: s__('Integrations|Link namespaces'),
+ title: s__('JiraConnect|Link groups'),
cancelProps: {
text: __('Cancel'),
},
},
};
</script>
+
<template>
<gl-modal
:modal-id="$options.modal.id"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index a4b728335c5..3d02dcb1198 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -65,7 +65,7 @@ export default {
this.groups = response.data;
})
.catch(() => {
- this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
+ this.errorMessage = s__('JiraConnect|Failed to load groups. Please try again.');
})
.finally(() => {
this.isLoadingMore = false;
@@ -102,20 +102,25 @@ export default {
</gl-alert>
<gl-search-box-by-type
- class="gl-mb-5"
+ class="gl-mb-3"
debounce="500"
- :placeholder="__('Search by name')"
+ :placeholder="__('Search groups')"
:is-loading="isLoadingMore"
:value="userSearchTerm"
@input="onGroupSearch"
/>
+ <p class="gl-mb-3">
+ {{
+ s__(
+ 'JiraConnect|Not seeing your groups? Only groups you have at least the Maintainer role for appear here.',
+ )
+ }}
+ </p>
+
<gl-loading-icon v-if="isLoadingInitial" size="lg" />
<div v-else-if="groups.length === 0" class="gl-text-center">
- <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
- <p class="gl-mt-5">
- {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }}
- </p>
+ <h5 class="gl-mt-5">{{ s__('JiraConnect|No groups found.') }}</h5>
</div>
<ul
v-else
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue
index ea7db5be0c4..d627e8cdd3a 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/browser_support_alert.vue
@@ -11,14 +11,15 @@ export default {
GlLink,
},
i18n: {
- title: s__('Integrations|Your browser is not supported'),
+ title: s__('JiraConnect|Your browser is not supported'),
body: s__(
- 'Integrations|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.',
+ 'JiraConnect|You must use a %{linkStart}supported browser%{linkEnd} to use the GitLab for Jira app.',
),
},
DOCS_LINK_URL: helpPagePath('install/requirements', { anchor: 'supported-web-browsers' }),
};
</script>
+
<template>
<gl-alert variant="danger" :title="$options.i18n.title" :dismissible="false">
<gl-sprintf :message="$options.i18n.body">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index bc8cdf35701..45a39fa5fab 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -183,6 +183,7 @@ export default {
},
};
</script>
+
<template>
<gl-button
v-bind="$attrs"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
index 4c039be9ba5..a765040a6e7 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue
@@ -24,11 +24,11 @@ export default {
fields: [
{
key: 'name',
- label: s__('Integrations|Linked namespaces'),
+ label: s__('JiraConnect|Linked groups'),
},
{
key: 'created_at',
- label: __('Added'),
+ label: __('Created on'),
tdClass: 'gl-vertical-align-middle! gl-w-20p',
},
{
@@ -38,7 +38,7 @@ export default {
},
],
i18n: {
- unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
+ unlinkError: s__('JiraConnect|Failed to unlink group. Please try again.'),
},
computed: {
...mapState(['subscriptions']),
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index cc0af0b9ab7..1e2c157b58d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -41,6 +41,7 @@ export default {
},
};
</script>
+
<template>
<div class="gl-font-base">
<gl-sprintf :message="signedInText">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 321d10205e6..72fd25a6230 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const DEFAULT_GROUPS_PER_PAGE = 10;
@@ -8,30 +8,30 @@ export const MINIMUM_SEARCH_TERM_LENGTH = 3;
export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
-export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
-export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to %{url}');
-export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
+export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = __('Sign in to GitLab');
+export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('JiraConnect|Sign in to %{url}');
+export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('JiraConnect|Failed to sign in to GitLab.');
export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
- 'Integrations|Failed to load subscriptions.',
+ 'JiraConnect|Failed to load subscriptions.',
);
export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__(
- 'Integrations|Namespace successfully linked',
+ 'JiraConnect|Group successfully linked',
);
export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__(
- 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
+ 'JiraConnect|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
);
export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
- 'Integrations|Failed to link namespace. Please try again.',
+ 'JiraConnect|Failed to link group. Please try again.',
);
export const I18N_UPDATE_INSTALLATION_ERROR_MESSAGE = s__(
- 'Integrations|Failed to update GitLab version. Please try again.',
+ 'JiraConnect|Failed to update the GitLab instance. See the %{linkStart}troubleshooting documentation%{linkEnd}.',
);
export const I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE = s__(
- 'Integrations|Failed to load Jira Connect Application ID. Please try again.',
+ 'JiraConnect|Failed to load Jira Connect Application ID. Please try again.',
);
-export const I18N_OAUTH_FAILED_TITLE = s__('Integrations|Failed to sign in to GitLab.');
+export const I18N_OAUTH_FAILED_TITLE = s__('JiraConnect|Failed to sign in to GitLab.');
export const I18N_OAUTH_FAILED_MESSAGE = s__(
- 'Integrations|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.',
+ 'JiraConnect|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.',
);
export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', {
@@ -40,6 +40,9 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_
export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', {
anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances',
});
+export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('integration/jira/connect-app', {
+ anchor: 'failed-to-update-the-gitlab-instance-for-self-managed-instances',
+});
export const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
index 113ce34fdcd..78bdb5caa77 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
@@ -17,8 +17,8 @@ export default {
},
},
i18n: {
- signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
- signInText: s__('JiraService|Sign in to GitLab to get started.'),
+ signInButtonTextWithSubscriptions: s__('JiraConnect|Sign in to link groups'),
+ signInText: s__('JiraConnect|Sign in to GitLab to get started.'),
},
GITLAB_COM_BASE_PATH,
methods: {
@@ -31,7 +31,7 @@ export default {
<template>
<div>
- <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <h2 class="gl-text-center gl-mb-7">{{ s__('JiraConnect|GitLab for Jira Configuration') }}</h2>
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<sign-in-oauth-button
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
index 8cc107930d1..e05eb900efa 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
@@ -8,6 +8,7 @@ import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/
import {
GITLAB_COM_BASE_PATH,
I18N_UPDATE_INSTALLATION_ERROR_MESSAGE,
+ FAILED_TO_UPDATE_DOC_LINK,
} from '~/jira_connect/subscriptions/constants';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
@@ -56,6 +57,7 @@ export default {
.catch(() => {
this.setAlert({
message: I18N_UPDATE_INSTALLATION_ERROR_MESSAGE,
+ linkUrl: FAILED_TO_UPDATE_DOC_LINK,
variant: 'danger',
});
this.loadingVersionSelect = false;
@@ -66,9 +68,9 @@ export default {
},
},
i18n: {
- title: s__('JiraService|Welcome to GitLab for Jira'),
- signInSubtitle: s__('JiraService|Sign in to GitLab to link namespaces.'),
- changeVersionButtonText: s__('JiraService|Change GitLab version'),
+ title: s__('JiraConnect|Welcome to GitLab for Jira'),
+ signInSubtitle: s__('JiraConnect|Sign in to GitLab to link groups.'),
+ changeVersionButtonText: s__('JiraConnect|Change GitLab version'),
},
};
</script>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
index 8ddbbffa708..cd71ded87b5 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
@@ -7,9 +7,9 @@ export default {
GlAlert,
},
i18n: {
- title: s__('JiraService|Are you a GitLab administrator?'),
+ title: s__('JiraConnect|Are you a GitLab administrator?'),
body: s__(
- "JiraService|Setting up this integration is only possible if you're a GitLab administrator.",
+ "JiraConnect|Setting up this integration is only possible if you're a GitLab administrator.",
),
},
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
index 621bcccd19a..d8d2db18d9f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
@@ -13,11 +13,11 @@ export default {
<template>
<div class="gl-mt-5">
- <h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3>
+ <h3>{{ s__('JiraConnect|Continue setup in GitLab') }}</h3>
<p>
{{
s__(
- 'JiraService|In order to complete the set up, you’ll need to complete a few steps in GitLab.',
+ 'JiraConnect|In order to complete the set up, you’ll need to complete a few steps in GitLab.',
)
}}
<gl-link
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
index 3a080afd3c5..d3770cc310a 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
@@ -56,7 +56,7 @@ export default {
? this.$options.i18n.buttonNext
: this.$options.i18n.buttonSave;
},
- showVersonSelect() {
+ showVersionSelect() {
return !this.showSetupInstructions && !this.showSelfManagedInstanceInput;
},
},
@@ -85,21 +85,21 @@ export default {
},
radioOptions: RADIO_OPTIONS,
i18n: {
- title: s__('JiraService|What version of GitLab are you using?'),
+ title: s__('JiraConnect|What version of GitLab are you using?'),
saasRadioLabel: __('GitLab.com (SaaS)'),
saasRadioHelp: __('Most common'),
selfManagedRadioLabel: __('GitLab (self-managed)'),
buttonNext: __('Next'),
buttonSave: __('Save'),
- instanceURLInputLabel: s__('JiraService|GitLab instance URL'),
- instanceURLInputDescription: s__('JiraService|For example: https://gitlab.example.com'),
+ instanceURLInputLabel: s__('JiraConnect|GitLab instance URL'),
+ instanceURLInputDescription: s__('JiraConnect|For example: https://gitlab.example.com'),
},
};
</script>
<template>
<gl-form class="gl-max-w-62 gl-mx-auto" @submit.prevent="onSubmit">
- <div v-if="showVersonSelect">
+ <div v-if="showVersionSelect">
<h5 class="gl-mb-5">{{ $options.i18n.title }}</h5>
<gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version">
<gl-form-radio :value="$options.radioOptions.saas">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
index ee20e21011f..87d7f73be2a 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
@@ -22,6 +22,7 @@ export default {
},
};
</script>
+
<template>
<sign-in-gitlab-multiversion
v-if="isOauthSelfManagedEnabled"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
index d7213f683d8..ac30fa2faa0 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div>
- <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
+ <h2 class="gl-text-center gl-mb-7">{{ s__('JiraConnect|GitLab for Jira Configuration') }}</h2>
<gl-loading-icon v-if="subscriptionsLoading" size="lg" />
<div v-else-if="hasSubscriptions && !subscriptionsError">
@@ -39,10 +39,10 @@ export default {
</div>
<gl-empty-state
v-else
- :title="s__('Integrations|No linked namespaces')"
+ :title="s__('JiraConnect|No linked groups')"
:description="
s__(
- 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ 'JiraConnect|Groups are the GitLab groups and subgroups you link to this Jira instance.',
)
"
>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index 28a17abb20b..9a88018205b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { Mousetrap } from '~/lib/mousetrap';
import { s__ } from '~/locale';
@@ -12,8 +12,7 @@ export default {
components: {
CiIcon,
ClipboardButton,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
GlLink,
GlSprintf,
},
@@ -32,6 +31,15 @@ export default {
},
},
computed: {
+ dropdownItems() {
+ return this.stages.map((stage) => ({
+ text: stage.name,
+ action: () => {
+ this.onStageClick(stage);
+ },
+ }));
+ },
+
hasRef() {
return !isEmpty(this.pipeline.ref);
},
@@ -153,15 +161,6 @@ export default {
</gl-sprintf>
</div>
- <gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3">
- <gl-dropdown-item
- v-for="stage in stages"
- :key="stage.name"
- class="js-stage-item stage-item"
- @click="onStageClick(stage)"
- >
- {{ stage.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown :toggle-text="selectedStage" :items="dropdownItems" class="gl-mt-3" />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
index 88a9f73258f..b692553fdc2 100644
--- a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
@@ -74,7 +74,7 @@ export default {
<div class="gl-text-truncate">
<gl-link
v-if="canReadJob"
- class="gl-text-gray-500!"
+ class="gl-text-blue-600!"
:href="jobPath"
data-testid="job-id-link"
>
@@ -92,9 +92,12 @@ export default {
/>
<div
- class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end"
+ class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end"
>
- <div v-if="jobRef" class="gl-max-w-15 gl-text-truncate">
+ <div
+ v-if="jobRef"
+ class="gl-px-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate"
+ >
<gl-icon
v-if="createdByTag"
name="label"
@@ -103,7 +106,7 @@ export default {
/>
<gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" />
<gl-link
- class="gl-font-weight-bold gl-text-gray-500!"
+ class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
:href="job.refPath"
data-testid="job-ref"
>{{ job.refName }}</gl-link
@@ -111,10 +114,15 @@ export default {
</div>
<span v-else>{{ __('none') }}</span>
-
- <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
-
- <gl-link :href="job.commitPath" data-testid="job-sha">{{ job.shortSha }}</gl-link>
+ <div class="gl-ml-2 gl-rounded-base gl-px-2 gl-bg-gray-50">
+ <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
+ <gl-link
+ class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
+ :href="job.commitPath"
+ data-testid="job-sha"
+ >{{ job.shortSha }}</gl-link
+ >
+ </div>
</div>
</div>
diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
index 298cc20ab35..ab50e6cdcd3 100644
--- a/app/assets/javascripts/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
@@ -3,6 +3,7 @@ import { GlSprintf, GlModal } from '@gitlab/ui';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -52,6 +53,12 @@ export default {
},
);
},
+ cleanedLabelColor() {
+ return stripQuotes(this.labelColor);
+ },
+ cleanedLabelTextColor() {
+ return stripQuotes(this.labelTextColor);
+ },
},
methods: {
onSubmit() {
@@ -97,7 +104,7 @@ export default {
<template #labelTitle>
<span
class="label color-label"
- :style="`background-color: ${labelColor}; color: ${labelTextColor};`"
+ :style="`background-color: ${cleanedLabelColor}; color: ${cleanedLabelTextColor};`"
>
{{ labelTitle }}
</span>
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index f5078962b8f..42682d9b79f 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -16,6 +16,21 @@ export function initScrollingTabs() {
const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
$scrollingTabs.addClass('is-initialized');
+ const el = $scrollingTabs.get(0);
+ const parentElement = el?.parentNode;
+ if (el && parentElement) {
+ parentElement
+ .querySelector('button.fade-left')
+ ?.addEventListener('click', function scrollLeft() {
+ el.scrollBy({ left: -200, behavior: 'smooth' });
+ });
+ parentElement
+ .querySelector('button.fade-right')
+ ?.addEventListener('click', function scrollRight() {
+ el.scrollBy({ left: 200, behavior: 'smooth' });
+ });
+ }
+
$(window)
.on('resize.nav', () => {
hideEndFade($scrollingTabs);
@@ -49,9 +64,31 @@ export function initScrollingTabs() {
});
}
-function initDeferred() {
- initScrollingTabs();
+function initInviteMembers() {
+ const modalEl = document.querySelector('.js-invite-members-modal');
+ if (!modalEl) return;
+
+ import(
+ /* webpackChunkName: 'initInviteMembersModal' */ '~/invite_members/init_invite_members_modal'
+ )
+ .then(({ default: initInviteMembersModal }) => {
+ initInviteMembersModal();
+ })
+ .catch(() => {});
+
+ const inviteTriggers = document.querySelectorAll('.js-invite-members-trigger');
+ if (!inviteTriggers) return;
+ import(
+ /* webpackChunkName: 'initInviteMembersTrigger' */ '~/invite_members/init_invite_members_trigger'
+ )
+ .then(({ default: initInviteMembersTrigger }) => {
+ initInviteMembersTrigger();
+ })
+ .catch(() => {});
+}
+
+function initWhatsNewComponent() {
const appEl = document.getElementById('whats-new-app');
if (!appEl) return;
@@ -69,6 +106,12 @@ function initDeferred() {
});
}
+function initDeferred() {
+ initScrollingTabs();
+ initWhatsNewComponent();
+ initInviteMembers();
+}
+
export default function initLayoutNav() {
if (!gon.use_new_navigation) {
const contextualSidebar = new ContextualSidebar();
diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js
index 8fc7c69c79d..f8ae180107c 100644
--- a/app/assets/javascripts/lib/apollo/persistence_mapper.js
+++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js
@@ -32,7 +32,9 @@ export const persistenceMapper = async (data) => {
persistEntities.push(...entities);
} else {
const entity = rootQuery[key].__ref;
- persistEntities.push(entity);
+ if (entity) {
+ persistEntities.push(entity);
+ }
}
}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index a4c13f9e40e..6ab530576fc 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -120,6 +120,8 @@ function createApolloClient(resolvers = {}, config = {}) {
cacheConfig = { typePolicies: {}, possibleTypes: {} },
fetchPolicy = fetchPolicies.CACHE_FIRST,
typeDefs,
+ httpHeaders = {},
+ fetchCredentials = 'same-origin',
path = '/api/graphql',
useGet = false,
} = config;
@@ -138,11 +140,12 @@ function createApolloClient(resolvers = {}, config = {}) {
uri,
headers: {
[csrf.headerKey]: csrf.token,
+ ...httpHeaders,
},
// fetch won’t send cookies in older browsers, unless you set the credentials init option.
// We set to `same-origin` which is default value in modern browsers.
// See https://github.com/whatwg/fetch/pull/585 for more information.
- credentials: 'same-origin',
+ credentials: fetchCredentials,
batchMax,
};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 9bf382c41e7..7795dac18bc 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -82,6 +82,7 @@ export const handleLocationHash = () => {
const fixedTabs = document.querySelector('.js-tabs-affix');
const fixedDiffStats = document.querySelector('.js-diff-files-changed');
const fixedNav = document.querySelector('.navbar-gitlab');
+ const fixedTopBar = document.querySelector('.top-bar-fixed');
const performanceBar = document.querySelector('#js-peek');
const topPadding = 8;
const diffFileHeader = document.querySelector('.js-file-title');
@@ -93,6 +94,7 @@ export const handleLocationHash = () => {
adjustment -= getElementOffsetHeight(fixedNav);
adjustment -= getElementOffsetHeight(fixedTabs);
adjustment -= getElementOffsetHeight(fixedDiffStats);
+ adjustment -= getElementOffsetHeight(fixedTopBar);
adjustment -= getElementOffsetHeight(performanceBar);
adjustment -= getElementOffsetHeight(diffFileHeader);
adjustment -= getElementOffsetHeight(versionMenusContainer);
@@ -153,6 +155,7 @@ export const contentTop = () => {
const heightCalculators = [
() => getOuterHeight('#js-peek'),
() => getOuterHeight('.navbar-gitlab'),
+ () => getOuterHeight('.top-bar-fixed'),
({ desktop }) => {
const mrStickyHeader = document.querySelector('.merge-request-sticky-header');
if (mrStickyHeader) {
@@ -689,21 +692,6 @@ export const getCookie = (name) => Cookies.get(name);
export const removeCookie = (name) => Cookies.remove(name);
/**
- * Returns the status of a feature flag.
- * Currently, there is no way to access feature
- * flags in Vuex other than directly tapping into
- * window.gon.
- *
- * This should only be used on Vuex. If feature flags
- * need to be accessed in Vue components consider
- * using the Vue feature flag mixin.
- *
- * @param {String} flag Feature flag
- * @returns {Boolean} on/off
- */
-export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag];
-
-/**
* This method takes in array with snake_case strings
* and returns a new array with camelCase strings
*
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index fb69a61880a..d1e5e4eea13 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -27,7 +27,7 @@ export const DRAWER_Z_INDEX = 252;
export const MIN_USERNAME_LENGTH = 2;
-export const BYTES_FORMAT_BYTES = 'Bytes';
+export const BYTES_FORMAT_BYTES = 'B';
export const BYTES_FORMAT_KIB = 'KiB';
export const BYTES_FORMAT_MIB = 'MiB';
export const BYTES_FORMAT_GIB = 'GiB';
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
index 9eb812b8694..d52672b9d08 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -730,3 +730,13 @@ export const getTimeRemainingInWords = (date) => {
const years = dateInFuture.getFullYear() - today.getFullYear();
return n__('1 year remaining', '%d years remaining', years);
};
+
+/**
+ * Returns the current date according to UTC time at midnight
+ * @return {Date} The current date in UTC
+ */
+export const getCurrentUtcDate = () => {
+ const now = new Date();
+
+ return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+};
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 198f2da385c..5f54243d4e5 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -114,7 +114,7 @@ export const setAttributes = (el, attributes) => {
* @param {String} contentWrapperClass the content wrapper class
* @returns {String} height in px
*/
-export const getContentWrapperHeight = (contentWrapperClass) => {
+export const getContentWrapperHeight = (contentWrapperClass = '.content-wrapper') => {
const wrapperEl = document.querySelector(contentWrapperClass);
return wrapperEl ? `${wrapperEl.offsetTop}px` : '';
};
diff --git a/app/assets/javascripts/lib/utils/listbox_helpers.js b/app/assets/javascripts/lib/utils/listbox_helpers.js
new file mode 100644
index 00000000000..b43a29ad28b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/listbox_helpers.js
@@ -0,0 +1,45 @@
+import { n__ } from '~/locale';
+
+/**
+ * Accepts an array of options and an array of selected option IDs
+ * and optionally a placeholder and maximum number of options to show.
+ *
+ * Returns a string with the text of the selected options:
+ * - If no options are selected, returns the placeholder or an empty string.
+ * - If less than maxOptionsShown is selected, returns the text of those options comma-separated.
+ * - If more than maxOptionsShown is selected, returns the text of those options comma-separated
+ * followed by the text "+X more", where X is the number of additional selected options
+ *
+ * @param {Object} opts
+ * @param {Array<{ id: number | string, value: string }>} opts.options
+ * @param {Array<{ id: number | string }>} opts.selected
+ * @param {String} opts.placeholder - Placeholder when no option is selected
+ * @param {Integer} opts.maxOptionsShown – Max number of options to show
+ * @returns {String}
+ */
+export const getSelectedOptionsText = ({
+ options,
+ selected,
+ placeholder = '',
+ maxOptionsShown = 1,
+}) => {
+ const selectedOptions = options.filter(({ id, value }) => selected.includes(id || value));
+
+ if (selectedOptions.length === 0) {
+ return placeholder;
+ }
+
+ const optionTexts = selectedOptions.map((option) => option.text);
+
+ if (selectedOptions.length <= maxOptionsShown) {
+ return optionTexts.join(', ');
+ }
+
+ // Prevent showing "+-1 more" when the array is empty.
+ const additionalItemsCount = selectedOptions.length - maxOptionsShown;
+ return `${optionTexts.slice(0, maxOptionsShown).join(', ')} ${n__(
+ '+%d more',
+ '+%d more',
+ additionalItemsCount,
+ )}`;
+};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index d64f84d2040..0e943cdb623 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -106,7 +106,7 @@ export function numberToHumanSize(size, digits = 2) {
switch (format) {
case BYTES_FORMAT_BYTES:
- return sprintf(__('%{size} bytes'), { size: humanSize });
+ return sprintf(__('%{size} B'), { size: humanSize });
case BYTES_FORMAT_KIB:
return sprintf(__('%{size} KiB'), { size: humanSize });
case BYTES_FORMAT_MIB:
diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js
index 2807911c9bb..8e673855631 100644
--- a/app/assets/javascripts/lib/utils/secret_detection.js
+++ b/app/assets/javascripts/lib/utils/secret_detection.js
@@ -11,19 +11,21 @@ export const i18n = {
primaryBtnText: __('Proceed'),
};
-const sensitiveDataPatterns = [
- {
- name: 'GitLab Personal Access Token',
- regex: 'glpat-[0-9a-zA-Z_-]{20}',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- name: 'Feed Token',
- regex: 'feed_token=[0-9a-zA-Z_-]{20}',
- },
-];
-
export const containsSensitiveToken = (message) => {
+ const patPrefix = window.gon?.pat_prefix || 'glpat-';
+
+ const sensitiveDataPatterns = [
+ {
+ name: 'GitLab Personal Access Token',
+ regex: `${patPrefix}[0-9a-zA-Z_-]{20}`,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Feed Token',
+ regex: 'feed_token=((glft-)?[0-9a-zA-Z_-]{20}|glft-[a-h0-9]+-[0-9]+_)',
+ },
+ ];
+
for (const rule of sensitiveDataPatterns) {
const regex = new RegExp(rule.regex, 'gi');
if (regex.test(message)) {
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 963041dd5d0..42f481261a2 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -568,3 +568,12 @@ export const humanizeBranchValidationErrors = (invalidChars = []) => {
}
return '';
};
+
+/**
+ * Strips enclosing quotations from a string if it has one.
+ *
+ * @param {String} value String to strip quotes from
+ *
+ * @returns {String} String without any enclosure
+ */
+export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2');
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index f16ff188edb..85740117c00 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,3 +1,5 @@
+import * as Sentry from '@sentry/browser';
+
export const DASH_SCOPE = '-';
export const PATH_SEPARATOR = '/';
@@ -8,12 +10,18 @@ const SHA_REGEX = /[\da-f]{40}/gi;
// GitLab default domain (override in jh)
export const DOMAIN = 'gitlab.com';
-// About GitLab default host (overwrite in jh)
+// Following URLs will be overwritten in jh
+export const FORUM_URL = `https://forum.${DOMAIN}/`; // forum.gitlab.com
+export const DOCS_URL = `https://docs.${DOMAIN}`; // docs.gitlab.com
+
+// About GitLab default host
export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com
-// About Gitlab default url (overwrite in jh)
+// About Gitlab default url
export const PROMO_URL = `https://${PROMO_HOST}`;
+export const DOCS_URL_IN_EE_DIR = `${DOCS_URL}/ee`;
+
// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
function resetRegExp(regex) {
regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
@@ -272,36 +280,6 @@ export const setUrlFragment = (url, fragment) => {
return `${rootUrl}#${encodedFragment}`;
};
-/**
- * Navigates to a URL
- * @param {*} url - url to navigate to
- * @param {*} external - if true, open a new page or tab
- */
-export function visitUrl(url, external = false) {
- if (external) {
- // Simulate `target="_blank" rel="noopener noreferrer"`
- // See https://mathiasbynens.github.io/rel-noopener/
- const otherWindow = window.open();
- otherWindow.opener = null;
- otherWindow.location = url;
- } else {
- window.location.href = url;
- }
-}
-
-export function refreshCurrentPage() {
- visitUrl(window.location.href);
-}
-
-/**
- * Navigates to a URL
- * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead
- * @param {*} url
- */
-export function redirectTo(url) {
- return window.location.assign(url);
-}
-
export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
if (win.history) {
if (replace) {
@@ -697,3 +675,41 @@ export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
*/
export const removeLastSlashInUrlPath = (url) =>
url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2');
+
+/**
+ * Navigates to a URL
+ * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead
+ * @param {*} url
+ */
+export function redirectTo(url) {
+ return window.location.assign(url);
+}
+
+/**
+ * Navigates to a URL
+ * @param {*} url - url to navigate to
+ * @param {*} external - if true, open a new page or tab
+ */
+export function visitUrl(url, external = false) {
+ if (!isSafeURL(url)) {
+ // For now log this to Sentry and do not block the execution.
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121551#note_1408873600
+ // for more context. Once we're sure that it's not breaking functionality, we can use
+ // a RangeError here (throw new RangeError('Only http and https protocols are allowed')).
+ Sentry.captureException(new RangeError(`Only http and https protocols are allowed: ${url}`));
+ }
+
+ if (external) {
+ // Simulate `target="_blank" rel="noopener noreferrer"`
+ // See https://mathiasbynens.github.io/rel-noopener/
+ const otherWindow = window.open();
+ otherWindow.opener = null;
+ otherWindow.location.assign(url);
+ } else {
+ window.location.assign(url);
+ }
+}
+
+export function refreshCurrentPage() {
+ visitUrl(window.location.href);
+}
diff --git a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
index 15606ad567c..23d6edae415 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
+++ b/app/assets/javascripts/members/components/action_dropdowns/leave_group_dropdown_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { LEAVE_MODAL_ID } from '../../constants';
import LeaveModal from '../modals/leave_modal.vue';
@@ -7,7 +7,7 @@ export default {
name: 'LeaveGroupDropdownItem',
modalId: LEAVE_MODAL_ID,
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
LeaveModal,
},
directives: {
@@ -27,10 +27,12 @@ export default {
</script>
<template>
- <gl-dropdown-item v-gl-modal="$options.modalId">
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- <leave-modal :member="member" :permissions="permissions" />
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item v-gl-modal="$options.modalId">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ <leave-modal :member="member" :permissions="permissions" />
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
index f224aaa31f7..627b47a1e81 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
+++ b/app/assets/javascripts/members/components/action_dropdowns/remove_member_dropdown_item.vue
@@ -1,10 +1,10 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
export default {
name: 'RemoveMemberDropdownItem',
- components: { GlDropdownItem },
+ components: { GlDisclosureDropdownItem },
inject: ['namespace'],
props: {
memberId: {
@@ -75,12 +75,14 @@ export default {
</script>
<template>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
data-qa-selector="delete_member_dropdown_item"
- @click="showRemoveMemberModal(modalData)"
+ @action="showRemoveMemberModal(modalData)"
>
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <template #list-item>
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
index c82ebadea6e..25dc4831b11 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
+++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import {
@@ -14,7 +14,7 @@ export default {
name: 'UserActionDropdown',
i18n: I18N,
components: {
- GlDropdown,
+ GlDisclosureDropdown,
DisableTwoFactorDropdownItem: () =>
import(
'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue'
@@ -99,15 +99,15 @@ export default {
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-if="showDropdown"
v-gl-tooltip="$options.i18n.actions"
- :text="$options.i18n.actions"
+ :toggle-text="$options.i18n.actions"
text-sr-only
icon="ellipsis_v"
category="tertiary"
no-caret
- right
+ placement="right"
data-testid="user-action-dropdown"
data-qa-selector="user_action_dropdown"
>
@@ -131,15 +131,16 @@ export default {
:user-deletion-obstacles="userDeletionObstaclesUserData"
:modal-message="modalRemoveUser"
:prevent-removal="permissions.canRemoveBlockedByLastOwner"
- >{{ $options.i18n.removeMember }}</remove-member-dropdown-item
>
+ {{ $options.i18n.removeMember }}
+ </remove-member-dropdown-item>
</template>
- <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{
- $options.i18n.editPermissions
- }}</ldap-override-dropdown-item>
- <ban-member-dropdown-item v-if="showBan" :member="member">{{
- $options.i18n.banMember
- }}</ban-member-dropdown-item>
- </gl-dropdown>
+ <ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">
+ {{ $options.i18n.editPermissions }}
+ </ldap-override-dropdown-item>
+ <ban-member-dropdown-item v-if="showBan" :member="member">
+ {{ $options.i18n.banMember }}
+ </ban-member-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index a85bb09e17b..4571c4172e5 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mapActions } from 'vuex';
import * as Sentry from '@sentry/browser';
@@ -9,10 +9,9 @@ import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_conf
export default {
name: 'RoleDropdown',
components: {
- GlDropdown,
- GlDropdownItem,
- LdapDropdownItem: () =>
- import('ee_component/members/components/action_dropdowns/ldap_dropdown_item.vue'),
+ GlCollapsibleListbox,
+ LdapDropdownFooter: () =>
+ import('ee_component/members/components/action_dropdowns/ldap_dropdown_footer.vue'),
},
inject: ['namespace', 'group'],
props: {
@@ -29,23 +28,22 @@ export default {
return {
isDesktop: false,
busy: false,
+ selectedRoleValue: this.member.accessLevel.integerValue,
};
},
computed: {
disabled() {
return this.permissions.canOverride && !this.member.isOverridden;
},
+ dropdownItems() {
+ return Object.entries(this.member.validRoles).map(([name, value]) => ({
+ value,
+ text: name,
+ }));
+ },
},
mounted() {
this.isDesktop = bp.isDesktop();
-
- // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle
- // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented
- const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
-
- if (dropdownToggle) {
- dropdownToggle.dataset.qaSelector = 'access_level_dropdown';
- }
},
methods: {
...mapActions({
@@ -63,7 +61,7 @@ export default {
memberType: this.namespace,
});
},
- async handleSelect(newRoleValue, newRoleName) {
+ async handleSelect(newRoleValue) {
const currentRoleValue = this.member.accessLevel.integerValue;
if (newRoleValue === currentRoleValue) {
return;
@@ -71,6 +69,7 @@ export default {
this.busy = true;
+ const { text: newRoleName } = this.dropdownItems.find((item) => item.value === newRoleValue);
const confirmed = await this.handleOverageConfirm(
currentRoleValue,
newRoleValue,
@@ -99,27 +98,25 @@ export default {
</script>
<template>
- <gl-dropdown
- ref="glDropdown"
- :right="!isDesktop"
- :text="member.accessLevel.stringValue"
+ <gl-collapsible-listbox
+ v-model="selectedRoleValue"
+ :placement="isDesktop ? 'left' : 'right'"
+ :toggle-text="member.accessLevel.stringValue"
:header-text="__('Change role')"
:disabled="disabled"
:loading="busy"
+ data-qa-selector="access_level_dropdown"
+ :items="dropdownItems"
+ @select="handleSelect"
>
- <gl-dropdown-item
- v-for="(value, name) in member.validRoles"
- :key="value"
- is-check-item
- :is-checked="value === member.accessLevel.integerValue"
- data-qa-selector="access_level_link"
- @click="handleSelect(value, name)"
- >
- {{ name }}
- </gl-dropdown-item>
- <ldap-dropdown-item
- v-if="permissions.canOverride && member.isOverridden"
- :member-id="member.id"
- />
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <span data-qa-selector="access_level_link">{{ item.text }}</span>
+ </template>
+ <template #footer>
+ <ldap-dropdown-footer
+ v-if="permissions.canOverride && member.isOverridden"
+ :member-id="member.id"
+ />
+ </template>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 4277e535d20..c837583dd45 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -5,7 +5,6 @@ import { createAlert } from '~/alert';
import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
-import eventHub from '~/vue_merge_request_widget/event_hub';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
@@ -145,17 +144,7 @@ MergeRequest.decreaseCounter = function (by = 1) {
$el.text(addDelimiter(count));
};
-MergeRequest.hideCloseButton = function () {
- const el = document.querySelector('.merge-request .js-issuable-actions');
- // Dropdown for mobile screen
- el.querySelector('li.js-close-item').classList.add('hidden');
-};
-
MergeRequest.toggleDraftStatus = function (title, isReady) {
- if (!window.gon?.features?.realtimeMrStatusChange) {
- eventHub.$emit('MRWidgetUpdateRequested');
- }
-
if (isReady) {
toast(__('Marked as ready. Merging is now allowed.'));
} else {
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index cef224d83e2..8307d0a9eed 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -97,6 +97,7 @@ function mountPipelines() {
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
fullPath: pipelineTableViewEl.dataset.fullPath,
manualActionsLimit: 50,
+ withFailedJobsDetails: true,
},
render(createElement) {
return createElement('commit-pipelines-table', {
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index e63b9613257..c6e8a9ea582 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -25,7 +25,7 @@ export default {
};
},
skip() {
- return !this.issuableId || !this.glFeatures.realtimeMrStatusChange;
+ return !this.issuableId;
},
result({ data: { mergeRequestMergeStatusUpdated } }) {
if (mergeRequestMergeStatusUpdated) {
@@ -115,10 +115,11 @@ export default {
>
<div class="gl-w-full gl-display-flex gl-align-items-center">
<status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
- <p
+ <a
v-safe-html:[$options.safeHtmlConfig]="titleHtml"
- class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4"
- ></p>
+ href="#top"
+ class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4 gl-text-black-normal"
+ ></a>
<div class="gl-display-flex gl-align-items-center">
<gl-sprintf :message="__('%{source} %{copyButton} into %{target}')">
<template #copyButton>
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index 8780d931588..420f7cee4d2 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -9,12 +9,18 @@ import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
import ZenMode from '~/zen_mode';
+import TaskList from '~/task_list';
+import { TYPE_MILESTONE } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
import eventHub from './event_hub';
// See app/views/shared/milestones/_description.html.haml
export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description';
+export const MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT = `${MILESTONE_DESCRIPTION_ELEMENT}.js-task-list-container`;
+export const MILESTONE_DETAIL_ELEMENT = '.milestone-detail';
export function initForm(initGFM = true) {
new ZenMode(); // eslint-disable-line no-new
@@ -40,6 +46,26 @@ export function initShow() {
new MountMilestoneSidebar(); // eslint-disable-line no-new
renderGFM(document.querySelector(MILESTONE_DESCRIPTION_ELEMENT));
+
+ const el = document.querySelector(MILESTONE_DESCRIPTION_TASK_LIST_CONTAINER_ELEMENT);
+
+ if (!el) {
+ return null;
+ }
+
+ return new TaskList({
+ dataType: TYPE_MILESTONE,
+ fieldName: 'description',
+ selector: MILESTONE_DETAIL_ELEMENT,
+ lockVersion: el.dataset.lockVersion,
+ onError: () => {
+ createAlert({
+ message: __(
+ 'Someone edited this milestone at the same time you did. Please refresh the page to see changes.',
+ ),
+ });
+ },
+ });
}
export function initPromoteMilestoneModal() {
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
index 20c5248052b..747e92b9e85 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
@@ -1,25 +1,11 @@
<script>
-import { GlLink } from '@gitlab/ui';
-
export default {
name: 'CandidateDetailRow',
- components: {
- GlLink,
- },
props: {
label: {
type: String,
required: true,
},
- text: {
- type: [String, Number],
- required: true,
- },
- href: {
- type: String,
- required: false,
- default: '',
- },
sectionLabel: {
type: String,
required: false,
@@ -34,8 +20,7 @@ export default {
<td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td>
<td class="gl-font-weight-bold">{{ label }}</td>
<td>
- <gl-link v-if="href" :href="href">{{ text }}</gl-link>
- <template v-else>{{ text }}</template>
+ <slot></slot>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
index 3ef73e7c874..a68fb7d340a 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
@@ -1,4 +1,5 @@
<script>
+import { GlAvatarLabeled, GlLink } from '@gitlab/ui';
import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
import DetailRow from './components/candidate_detail_row.vue';
@@ -17,6 +18,10 @@ import {
DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
DELETE_CANDIDATE_MODAL_TITLE,
MLFLOW_ID_LABEL,
+ CI_SECTION_LABEL,
+ JOB_LABEL,
+ CI_USER_LABEL,
+ CI_MR_LABEL,
} from './translations';
export default {
@@ -25,6 +30,8 @@ export default {
ModelExperimentsHeader,
DeleteButton,
DetailRow,
+ GlAvatarLabeled,
+ GlLink,
},
props: {
candidate: {
@@ -43,11 +50,18 @@ export default {
DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
DELETE_CANDIDATE_MODAL_TITLE,
MLFLOW_ID_LABEL,
+ CI_SECTION_LABEL,
+ JOB_LABEL,
+ CI_USER_LABEL,
+ CI_MR_LABEL,
},
computed: {
info() {
return Object.freeze(this.candidate.info);
},
+ ciJob() {
+ return Object.freeze(this.info.ci_job);
+ },
sections() {
return [
{
@@ -83,28 +97,52 @@ export default {
<tbody>
<tr class="divider"></tr>
- <detail-row
- :label="$options.i18n.ID_LABEL"
- :section-label="$options.i18n.INFO_LABEL"
- :text="info.iid"
- />
+ <detail-row :label="$options.i18n.ID_LABEL" :section-label="$options.i18n.INFO_LABEL">
+ {{ info.iid }}
+ </detail-row>
+
+ <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row>
- <detail-row :label="$options.i18n.MLFLOW_ID_LABEL" :text="info.eid" />
+ <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row>
- <detail-row :label="$options.i18n.STATUS_LABEL" :text="info.status" />
+ <detail-row :label="$options.i18n.EXPERIMENT_LABEL">
+ <gl-link :href="info.path_to_experiment">
+ {{ info.experiment_name }}
+ </gl-link>
+ </detail-row>
- <detail-row
- :label="$options.i18n.EXPERIMENT_LABEL"
- :text="info.experiment_name"
- :href="info.path_to_experiment"
- />
+ <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL">
+ <gl-link :href="info.path_to_artifact">
+ {{ $options.i18n.ARTIFACTS_LABEL }}
+ </gl-link>
+ </detail-row>
- <detail-row
- v-if="info.path_to_artifact"
- :label="$options.i18n.ARTIFACTS_LABEL"
- :href="info.path_to_artifact"
- :text="$options.i18n.ARTIFACTS_LABEL"
- />
+ <template v-if="ciJob">
+ <tr class="divider"></tr>
+
+ <detail-row
+ :label="$options.i18n.JOB_LABEL"
+ :section-label="$options.i18n.CI_SECTION_LABEL"
+ >
+ <gl-link :href="ciJob.path">
+ {{ ciJob.name }}
+ </gl-link>
+ </detail-row>
+
+ <detail-row v-if="ciJob.user" :label="$options.i18n.CI_USER_LABEL">
+ <gl-avatar-labeled label="" :size="24" :src="ciJob.user.avatar">
+ <gl-link :href="ciJob.user.path">
+ {{ ciJob.user.name }}
+ </gl-link>
+ </gl-avatar-labeled>
+ </detail-row>
+
+ <detail-row v-if="ciJob.merge_request" :label="$options.i18n.CI_MR_LABEL">
+ <gl-link :href="ciJob.merge_request.path">
+ !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }}
+ </gl-link>
+ </detail-row>
+ </template>
<template v-for="{ sectionName, sectionValues } in sections">
<tr v-if="sectionValues" :key="sectionName" class="divider"></tr>
@@ -114,8 +152,9 @@ export default {
:key="item.name"
:label="item.name"
:section-label="index === 0 ? sectionName : ''"
- :text="item.value"
- />
+ >
+ {{ item.value }}
+ </detail-row>
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
index 66ee84adb4e..fa9518f3e27 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const TITLE_LABEL = s__('MlExperimentTracking|Model candidate details');
export const INFO_LABEL = s__('MlExperimentTracking|Info');
@@ -15,3 +15,7 @@ export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__(
);
export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate');
export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?');
+export const CI_SECTION_LABEL = __('CI');
+export const JOB_LABEL = __('Job');
+export const CI_USER_LABEL = s__('MlExperimentTracking|Triggered by');
+export const CI_MR_LABEL = __('Merge request');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
index 66f94c6bee5..b543169d501 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
@@ -51,27 +51,29 @@ export default {
</script>
<template>
- <div v-if="hasExperiments">
+ <div>
<model-experiments-header :page-title="$options.i18n.TITLE_LABEL" />
- <gl-table-lite :items="tableItems" :fields="$options.tableFields">
- <template #cell(nameColumn)="data">
- <gl-link :href="data.value.path">
- {{ data.value.name }}
- </gl-link>
- </template>
- </gl-table-lite>
+ <template v-if="hasExperiments">
+ <gl-table-lite :items="tableItems" :fields="$options.tableFields">
+ <template #cell(nameColumn)="data">
+ <gl-link :href="data.value.path">
+ {{ data.value.name }}
+ </gl-link>
+ </template>
+ </gl-table-lite>
- <pagination v-if="hasExperiments" v-bind="pageInfo" />
- </div>
+ <pagination v-if="hasExperiments" v-bind="pageInfo" />
+ </template>
- <gl-empty-state
- v-else
- :title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
- :primary-button-text="$options.i18n.CREATE_NEW_LABEL"
- :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
- class="gl-py-8"
- />
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
+ :primary-button-text="$options.i18n.CREATE_NEW_LABEL"
+ :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
+ class="gl-py-8"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js
index e954c054cf5..f556197633b 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js
@@ -4,8 +4,10 @@ export const TITLE_LABEL = s__('MlExperimentTracking|Model experiments');
export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create a new experiment');
-export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No experiments');
+export const EMPTY_STATE_TITLE_LABEL = s__(
+ 'MlExperimentTracking|Get started with model experiments!',
+);
export const EMPTY_STATE_DESCRIPTION_LABEL = s__(
- 'MlExperimentTracking|There are no logged experiments for this project. Create a new experiment using the MLflow client.',
+ 'MlExperimentTracking|Experiments keep track of comparable model candidates, and determine which parameters provides the best performance. Create experiments using the MLflow client',
);
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index da4c92df711..6419c45c20c 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -1,5 +1,5 @@
<script>
-import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg';
+import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg?raw';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { chartHeight } from '../../constants';
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 752ba4241d8..cfc20b7b95f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -11,16 +11,8 @@ import {
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
import { createAlert } from '~/alert';
-import {
- keysFor,
- METRICS_COPY_LINK_TO_CHART,
- METRICS_DOWNLOAD_CSV,
- METRICS_EXPAND_PANEL,
- METRICS_SHOW_ALERTS,
-} from '~/behaviors/shortcuts/keybindings';
import invalidUrl from '~/lib/utils/invalid_url';
import { ESC_KEY } from '~/lib/utils/keys';
-import { Mousetrap } from '~/lib/mousetrap';
import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { defaultTimeRange } from '~/vue_shared/constants';
@@ -218,32 +210,6 @@ export default {
}
},
},
- created() {
- window.addEventListener('keyup', this.onKeyup);
-
- Mousetrap.bind(keysFor(METRICS_EXPAND_PANEL), () =>
- this.runShortcut('onExpandFromKeyboardShortcut'),
- );
- Mousetrap.bind(keysFor(METRICS_SHOW_ALERTS), () =>
- this.runShortcut('showAlertModalFromKeyboardShortcut'),
- );
- Mousetrap.bind(keysFor(METRICS_DOWNLOAD_CSV), () =>
- this.runShortcut('downloadCsvFromKeyboardShortcut'),
- );
- Mousetrap.bind(keysFor(METRICS_COPY_LINK_TO_CHART), () =>
- this.runShortcut('copyChartLinkFromKeyboardShotcut'),
- );
- },
- destroyed() {
- window.removeEventListener('keyup', this.onKeyup);
-
- [
- METRICS_COPY_LINK_TO_CHART,
- METRICS_DOWNLOAD_CSV,
- METRICS_EXPAND_PANEL,
- METRICS_SHOW_ALERTS,
- ].forEach((command) => Mousetrap.unbind(keysFor(command)));
- },
mounted() {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 44dde454983..f4dc29f2184 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -105,7 +105,7 @@ export default {
return (
this.customMetricsAvailable &&
!this.shouldShowEmptyState &&
- // Custom metrics only avaialble on system dashboards because
+ // Custom metrics only available on system dashboards because
// they are stored in the database. This can be improved. See:
// https://gitlab.com/gitlab-org/gitlab/-/issues/28241
this.selectedDashboard?.out_of_the_box_dashboard
diff --git a/app/assets/javascripts/mr_more_dropdown.js b/app/assets/javascripts/mr_more_dropdown.js
new file mode 100644
index 00000000000..720619b72ae
--- /dev/null
+++ b/app/assets/javascripts/mr_more_dropdown.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import MrMoreDropdown from '~/vue_shared/components/mr_more_dropdown.vue';
+
+export const initMrMoreDropdown = () => {
+ const el = document.querySelector('.js-mr-more-dropdown');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ mergeRequest,
+ projectPath,
+ editUrl,
+ isCurrentUser,
+ isLoggedIn,
+ canUpdateMergeRequest,
+ open,
+ merged,
+ sourceProjectMissing,
+ clipboardText,
+ reportedUserId,
+ reportedFromUrl,
+ } = el.dataset;
+
+ let mr;
+
+ try {
+ mr = JSON.parse(mergeRequest);
+ } catch {
+ mr = {};
+ }
+
+ return new Vue({
+ el,
+ provide: {
+ reportAbusePath: el.dataset.reportAbusePath,
+ },
+ render: (createElement) =>
+ createElement(MrMoreDropdown, {
+ props: {
+ mr,
+ projectPath,
+ editUrl,
+ isCurrentUser,
+ isLoggedIn: Boolean(isLoggedIn),
+ canUpdateMergeRequest,
+ open,
+ isMerged: merged,
+ sourceProjectMissing,
+ clipboardText,
+ reportedUserId: Number(reportedUserId),
+ reportedFromUrl,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js
index 9852efea95f..e8e3376cee2 100644
--- a/app/assets/javascripts/mr_notes/init.js
+++ b/app/assets/javascripts/mr_notes/init.js
@@ -1,12 +1,14 @@
import { parseBoolean } from '~/lib/utils/common_utils';
-import store from '~/mr_notes/stores';
+import mrNotes from '~/mr_notes/stores';
import { getLocationHash } from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import { initReviewBar } from '~/batch_comments';
import { initDiscussionCounter } from '~/mr_notes/discussion_counter';
import { initOverviewTabCounter } from '~/mr_notes/init_count';
+import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
+import { getReviewsForMergeRequest } from '~/diffs/utils/file_reviews';
-function setupMrNotesState(notesDataset) {
+function setupMrNotesState(store, notesDataset, diffsDataset) {
const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
@@ -15,26 +17,43 @@ function setupMrNotesState(notesDataset) {
const currentUserData = JSON.parse(notesDataset.currentUserData);
const endpoints = { metadata: notesDataset.endpointMetadata };
+ const { mrPath } = getDerivedMergeRequestInformation({ endpoint: diffsDataset.endpoint });
+
store.dispatch('setNotesData', notesData);
store.dispatch('setNoteableData', noteableData);
store.dispatch('setUserData', currentUserData);
store.dispatch('setTargetNoteHash', getLocationHash());
store.dispatch('setEndpoints', endpoints);
+ store.dispatch('diffs/setBaseConfig', {
+ endpoint: diffsDataset.endpoint,
+ endpointMetadata: diffsDataset.endpointMetadata,
+ endpointBatch: diffsDataset.endpointBatch,
+ endpointDiffForPath: diffsDataset.endpointDiffForPath,
+ endpointCoverage: diffsDataset.endpointCoverage,
+ endpointUpdateUser: diffsDataset.updateCurrentUserPath,
+ projectPath: diffsDataset.projectPath,
+ dismissEndpoint: diffsDataset.dismissEndpoint,
+ showSuggestPopover: parseBoolean(diffsDataset.showSuggestPopover),
+ viewDiffsFileByFile: parseBoolean(diffsDataset.fileByFileDefault),
+ defaultSuggestionCommitMessage: diffsDataset.defaultSuggestionCommitMessage,
+ mrReviews: getReviewsForMergeRequest(mrPath),
+ });
}
-export function initMrStateLazyLoad({ reviewBarParams } = {}) {
+export function initMrStateLazyLoad(store = mrNotes, { reviewBarParams } = {}) {
store.dispatch('setActiveTab', window.mrTabs.getCurrentAction());
window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) =>
store.dispatch('setActiveTab', value),
);
const discussionsEl = document.getElementById('js-vue-mr-discussions');
- const notesDataset = discussionsEl.dataset;
+ const diffsEl = document.getElementById('js-diffs-app');
+
let stop = () => {};
stop = store.watch(
(state) => state.page.activeTab,
(activeTab) => {
- setupMrNotesState(notesDataset);
+ setupMrNotesState(store, discussionsEl.dataset, diffsEl.dataset);
// prevent loading MR state on commits and pipelines pages
// this is due to them having a shared controller with the Overview page
diff --git a/app/assets/javascripts/mr_notes/init_mr_notes.js b/app/assets/javascripts/mr_notes/init_mr_notes.js
index e0a8d1f7e7d..3fcf0958868 100644
--- a/app/assets/javascripts/mr_notes/init_mr_notes.js
+++ b/app/assets/javascripts/mr_notes/init_mr_notes.js
@@ -13,7 +13,7 @@ export default function initMrNotes(lazyLoadParams) {
action: mrShowNode.dataset.mrAction,
});
- initMrStateLazyLoad(lazyLoadParams);
+ initMrStateLazyLoad(undefined, lazyLoadParams);
document.addEventListener('merged:UpdateActions', () => {
initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 6794f838c84..cba0f960c00 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -6,7 +6,6 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests
import { createAlert } from '~/alert';
import { badgeState } from '~/issuable/components/status_box.vue';
import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
-import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
capitalizeFirstCharacter,
@@ -21,6 +20,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import * as constants from '../constants';
import eventHub from '../event_hub';
import { COMMENT_FORM } from '../i18n';
+import { getErrorMessages } from '../utils';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
@@ -219,11 +219,7 @@ export default {
'toggleIssueLocalState',
]),
handleSaveError({ data, status }) {
- if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) {
- this.errors = data.errors.commands_only;
- } else {
- this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK];
- }
+ this.errors = getErrorMessages(data, status);
},
handleSaveDraft() {
this.handleSave({ isDraft: true });
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index f949142d90a..c53d3203327 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -5,6 +5,7 @@ import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
+import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import NoteEditedText from './note_edited_text.vue';
import NoteHeader from './note_header.vue';
@@ -62,6 +63,7 @@ export default {
for_commit: isForCommit,
diff_discussion: isDiffDiscussion,
active: isActive,
+ position,
} = this.discussion;
let text = s__('MergeRequests|started a thread');
@@ -75,6 +77,10 @@ export default {
: s__(
'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}',
);
+ } else if (isDiffDiscussion && position?.position_type === FILE_DIFF_POSITION_TYPE) {
+ text = isActive
+ ? s__('MergeRequests|started a thread on %{linkStart}a file%{linkEnd}')
+ : s__('MergeRequests|started a thread on %{linkStart}an old version of a file%{linkEnd}');
} else if (isDiffDiscussion) {
text = isActive
? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index aabdc1c99b6..db32079e6b9 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -8,6 +8,7 @@ import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { isCollapsed } from '~/diffs/utils/diff_file';
+import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@@ -42,6 +43,15 @@ export default {
diffViewerMode() {
return this.discussion.diff_file.viewer.name;
},
+ fileDiffRefs() {
+ return this.discussion.diff_file.diff_refs;
+ },
+ headSha() {
+ return (this.fileDiffRefs ? this.fileDiffRefs.head_sha : this.discussion.commit_id) || '';
+ },
+ baseSha() {
+ return (this.fileDiffRefs ? this.fileDiffRefs.base_sha : this.discussion.commit_id) || '';
+ },
isTextFile() {
return this.diffViewerMode === diffViewerModes.text;
},
@@ -53,6 +63,12 @@ export default {
isCollapsed() {
return isCollapsed(this.discussion.diff_file);
},
+ positionType() {
+ return this.discussion.position?.position_type;
+ },
+ isFileDiscussion() {
+ return this.positionType === FILE_DIFF_POSITION_TYPE;
+ },
},
mounted() {
if (this.isTextFile && !this.hasTruncatedDiffLines) {
@@ -87,50 +103,59 @@ export default {
/>
<div v-if="isTextFile" class="diff-content">
<table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
- <template v-if="hasTruncatedDiffLines">
- <tr
- v-for="line in discussion.truncated_diff_lines"
- v-once
- :key="line.line_code"
- class="line_holder"
- >
- <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
- <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
- <td v-safe-html="trimChar(line.rich_text)" :class="line.type" class="line_content"></td>
+ <template v-if="!isFileDiscussion">
+ <template v-if="hasTruncatedDiffLines">
+ <tr
+ v-for="line in discussion.truncated_diff_lines"
+ v-once
+ :key="line.line_code"
+ class="line_holder"
+ >
+ <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
+ <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
+ <td
+ v-safe-html="trimChar(line.rich_text)"
+ :class="line.type"
+ class="line_content"
+ ></td>
+ </tr>
+ </template>
+ <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
+ <td class="old_line diff-line-num"></td>
+ <td class="new_line diff-line-num"></td>
+ <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
+ {{ __('Unable to load the diff') }}
+ <button
+ class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!"
+ @click="fetchDiff"
+ >
+ {{ __('Try again') }}
+ </button>
+ </td>
+ <td v-else class="line_content js-success-lazy-load">
+ <span></span>
+ <gl-skeleton-loader />
+ <span></span>
+ </td>
</tr>
</template>
- <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
- <td class="old_line diff-line-num"></td>
- <td class="new_line diff-line-num"></td>
- <td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
- {{ __('Unable to load the diff') }}
- <button
- class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!"
- @click="fetchDiff"
- >
- {{ __('Try again') }}
- </button>
- </td>
- <td v-else class="line_content js-success-lazy-load">
- <span></span>
- <gl-skeleton-loader />
- <span></span>
- </td>
- </tr>
<tr class="notes_holder">
- <td class="notes-content" colspan="3"><slot></slot></td>
+ <td :class="{ 'gl-border-top-0!': isFileDiscussion }" class="notes-content" colspan="3">
+ <slot></slot>
+ </td>
</tr>
</table>
</div>
- <div v-else>
+ <div v-else class="diff-content">
<diff-viewer
+ v-if="!isFileDiscussion"
:diff-file="discussion.diff_file"
:diff-mode="diffMode"
:diff-viewer-mode="diffViewerMode"
:new-path="discussion.diff_file.new_path"
- :new-sha="discussion.diff_file.diff_refs.head_sha"
+ :new-sha="headSha"
:old-path="discussion.diff_file.old_path"
- :old-sha="discussion.diff_file.diff_refs.base_sha"
+ :old-sha="baseSha"
:file-hash="discussion.diff_file.file_hash"
:project-path="projectPath"
>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 3e8cddc3174..9fb027fb955 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { SYSTEM_NOTE } from '../constants';
import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -82,6 +83,12 @@ export default {
url: this.discussion.discussion_path,
};
},
+ isDiscussionInternal() {
+ return this.discussion.notes[0]?.internal;
+ },
+ isFileDiscussion() {
+ return this.discussion.position?.position_type === FILE_DIFF_POSITION_TYPE;
+ },
},
methods: {
...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']),
@@ -139,6 +146,8 @@ export default {
:discussion-resolve-path="discussion.resolve_path"
:is-overview-tab="isOverviewTab"
:should-scroll-to-note="shouldScrollToNote"
+ :internal-note="isDiscussionInternal"
+ :class="{ 'gl-border-top-0!': isFileDiscussion }"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -171,6 +180,7 @@ export default {
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="line"
+ :internal-note="isDiscussionInternal"
@handleDeleteNote="$emit('deleteNote')"
/>
</template>
@@ -190,6 +200,7 @@ export default {
:discussion-resolve-path="discussion.resolve_path"
:is-overview-tab="isOverviewTab"
:should-scroll-to-note="shouldScrollToNote"
+ :internal-note="isDiscussionInternal"
@handleDeleteNote="$emit('deleteNote')"
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 27fb116d213..47e0ace1ea7 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -214,22 +214,18 @@ export default {
methods: {
...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
- this.closeMoreActionsDropdown();
this.$emit('handleEdit');
},
onDelete() {
- this.closeMoreActionsDropdown();
this.$emit('handleDelete');
},
onResolve() {
this.$emit('handleResolve');
},
onAbuse() {
- this.closeMoreActionsDropdown();
this.toggleReportAbuseDrawer(true);
},
onCopyUrl() {
- this.closeMoreActionsDropdown();
this.$toast.show(__('Link copied to clipboard.'));
},
handleAssigneeUpdate(assignees) {
@@ -241,8 +237,6 @@ export default {
let { assignees } = this;
const { project_id, iid } = this.getNoteableData;
- this.closeMoreActionsDropdown();
-
if (this.isUserAssigned) {
assignees = assignees.filter((assignee) => assignee.id !== this.author.id);
} else {
@@ -271,11 +265,6 @@ export default {
toggleReportAbuseDrawer(isOpen) {
this.isReportAbuseDrawerOpen = isOpen;
},
- closeMoreActionsDropdown() {
- if (this.shouldShowActionsDropdown && this.$refs.moreActionsDropdown) {
- this.$refs.moreActionsDropdown.close();
- }
- },
},
};
</script>
@@ -374,7 +363,6 @@ export default {
/>
<div v-else-if="shouldShowActionsDropdown" class="more-actions dropdown">
<gl-disclosure-dropdown
- ref="moreActionsDropdown"
v-gl-tooltip
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index 8c8cc7984b1..18dd3f4366c 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -22,7 +22,7 @@ export default {
data-track-action="click_button"
data-track-label="reply_comment_button"
category="tertiary"
- icon="comment"
+ icon="reply"
:title="$options.i18n.buttonText"
:aria-label="$options.i18n.buttonText"
@click="$emit('startReplying')"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 2cf6e9bb180..fe7967f1ed0 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -94,6 +94,11 @@ export default {
required: false,
default: false,
},
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -359,7 +364,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
:disabled="isSubmitting"
supports-quick-actions
- autofocus
+ :autofocus="autofocus"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 5e776639a7a..83cebb9a0e0 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -8,8 +8,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeAgoTooltip,
- GitlabTeamMemberBadge: () =>
- import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
GlBadge,
GlLoadingIcon,
@@ -199,7 +197,6 @@ export default {
><span class="note-headline-light">@{{ author.username }}</span>
</a>
<slot name="note-header-info"></slot>
- <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
</span>
<span v-if="emailParticipant" class="note-headline-light">{{
__('(external participant)')
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 375b16f6ce2..499581653ba 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -15,6 +15,7 @@ import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secr
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
+import { getErrorMessages } from '../utils';
import DiffDiscussionHeader from './diff_discussion_header.vue';
import DiffWithNote from './diff_with_note.vue';
import DiscussionActions from './discussion_actions.vue';
@@ -162,6 +163,17 @@ export default {
return true;
},
+ isDiscussionInternal() {
+ return this.discussion.notes[0]?.internal;
+ },
+ discussionHolderClass() {
+ return {
+ 'is-replying gl-pt-0!': this.isReplying,
+ 'internal-note': this.isDiscussionInternal,
+ 'public-note': !this.isDiscussionInternal,
+ 'gl-pt-0!': !this.discussion.diff_discussion && this.isReplying,
+ };
+ },
},
created() {
eventHub.$on('startReplying', this.onStartReplying);
@@ -244,26 +256,24 @@ export default {
};
this.saveNote(replyData)
- .then((res) => {
- if (res.hasAlert !== true) {
- this.isReplying = false;
- clearDraft(this.autosaveKey);
- }
+ .then(() => {
+ this.isReplying = false;
+ clearDraft(this.autosaveKey);
+
callback();
})
.catch((err) => {
- this.removePlaceholderNotes();
this.handleSaveError(err); // The 'err' parameter is being used in JH, don't remove it
- this.$refs.noteForm.note = noteText;
+ this.removePlaceholderNotes();
+
callback(err);
});
},
- handleSaveError() {
- const msg = __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- );
+ handleSaveError({ response }) {
+ const errorMessage = getErrorMessages(response.data, response.status)[0];
+
createAlert({
- message: msg,
+ message: errorMessage,
parent: this.$el,
});
},
@@ -284,6 +294,7 @@ export default {
<div class="timeline-content">
<div
:data-discussion-id="discussion.id"
+ :data-discussion-resolvable="discussion.resolvable"
:data-discussion-resolved="discussion.resolved"
class="discussion js-discussion-container"
data-qa-selector="discussion_content"
@@ -319,8 +330,9 @@ export default {
/>
<li
v-else-if="canShowReplyActions && showReplies"
- :class="{ 'is-replying gl-bg-white! gl-pt-0!': isReplying }"
+ data-testid="reply-wrapper"
class="discussion-reply-holder gl-border-t-0! clearfix"
+ :class="discussionHolderClass"
>
<discussion-actions
v-if="!isReplying && userCanReply"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 5929e419247..dd135eaee3b 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -18,6 +18,7 @@ import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import { renderMarkdown } from '../utils';
+import { UPDATE_COMMENT_FORM } from '../i18n';
import {
getStartLineNumber,
getEndLineNumber,
@@ -113,6 +114,7 @@ export default {
isResolving: false,
commentLineStart: {},
resolveAsThread: true,
+ oldContent: this.note.note_html,
};
},
computed: {
@@ -293,7 +295,7 @@ export default {
updateSuccess() {
this.isEditing = false;
this.isRequesting = false;
- this.oldContent = null;
+ this.oldContent = this.note.note_html;
renderGFM(this.$refs.noteBody.$el);
this.$emit('updateSuccess');
},
@@ -341,7 +343,6 @@ export default {
// https://gitlab.com/gitlab-org/gitlab/-/issues/298827
if (!isEmpty(position)) data.note.note.position = JSON.stringify(position);
this.isRequesting = true;
- this.oldContent = this.note.note_html;
// eslint-disable-next-line vue/no-mutating-props
this.note.note_html = renderMarkdown(noteText);
@@ -350,8 +351,8 @@ export default {
this.updateSuccess();
callback();
})
- .catch((response) => {
- if (response.status === HTTP_STATUS_GONE) {
+ .catch((e) => {
+ if (e.status === HTTP_STATUS_GONE) {
this.removeNote(this.note);
this.updateSuccess();
callback();
@@ -360,17 +361,22 @@ export default {
this.isEditing = true;
this.setSelectedCommentPositionHover();
this.$nextTick(() => {
- this.handleUpdateError(response); // The 'response' parameter is being used in JH, don't remove it
- this.recoverNoteContent(noteText);
+ this.handleUpdateError(e); // The 'e' parameter is being used in JH, don't remove it
+ this.recoverNoteContent();
callback();
});
}
});
},
- handleUpdateError() {
- const msg = __('Something went wrong while editing your comment. Please try again.');
+ handleUpdateError(e) {
+ const serverErrorMessage = e?.response?.data?.errors;
+
+ const alertMessage = serverErrorMessage
+ ? sprintf(UPDATE_COMMENT_FORM.error, { reason: serverErrorMessage.toLowerCase() }, false)
+ : UPDATE_COMMENT_FORM.defaultError;
+
createAlert({
- message: msg,
+ message: alertMessage,
parent: this.$el,
});
},
@@ -391,22 +397,14 @@ export default {
});
if (!confirmed) return;
}
- if (this.oldContent) {
- // eslint-disable-next-line vue/no-mutating-props
- this.note.note_html = this.oldContent;
- this.oldContent = null;
- }
+ this.recoverNoteContent();
this.isEditing = false;
this.$emit('cancelForm');
}),
- recoverNoteContent(noteText) {
- // we need to do this to prevent noteForm inconsistent content warning
- // this is something we intentionally do so we need to recover the content
- // eslint-disable-next-line vue/no-mutating-props
- this.note.note = noteText;
- const { noteBody } = this.$refs;
- if (noteBody) {
- noteBody.note.note = noteText;
+ recoverNoteContent() {
+ if (this.oldContent) {
+ // eslint-disable-next-line vue/no-mutating-props
+ this.note.note_html = this.oldContent;
}
},
getLineClasses(lineNumber) {
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index 4bf2a8d70a7..c25ca6b586d 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -4,6 +4,7 @@ export const COMMENT_FORM = {
GENERIC_UNSUBMITTABLE_NETWORK: __(
'Your comment could not be submitted! Please check your network connection and try again.',
),
+ error: __('Your comment could not be submitted because %{reason}.'),
note: __('Note'),
comment: __('Comment'),
internalComment: __('Add internal note'),
@@ -54,3 +55,8 @@ export const EDITED_TEXT = {
actionWithAuthor: __('%{actionText} %{actionDetail} %{timeago} by %{author}'),
actionWithoutAuthor: __('%{actionText} %{actionDetail}'),
};
+
+export const UPDATE_COMMENT_FORM = {
+ error: __('Your comment could not be updated because %{reason}.'),
+ defaultError: __('Something went wrong while editing your comment. Please try again.'),
+};
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 0509ff24959..55a63212dc5 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,6 +1,10 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
-import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
+import {
+ TEXT_DIFF_POSITION_TYPE,
+ IMAGE_DIFF_POSITION_TYPE,
+ FILE_DIFF_POSITION_TYPE,
+} from '~/diffs/constants';
import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
import { s__ } from '~/locale';
@@ -15,10 +19,10 @@ export default {
}),
...mapGetters('diffs', ['getDiffFileByHash']),
...mapGetters('batchComments', ['shouldRenderDraftRowInDiscussion', 'draftForDiscussion']),
- ...mapState('diffs', ['commit']),
+ ...mapState('diffs', ['commit', 'showWhitespace']),
},
methods: {
- ...mapActions('diffs', ['cancelCommentForm']),
+ ...mapActions('diffs', ['cancelCommentForm', 'toggleFileCommentForm']),
...mapActions('batchComments', ['addDraftToReview', 'saveDraft', 'insertDraftIntoDrafts']),
addReplyToReview(noteText, isResolving) {
const postData = getDraftReplyFormData({
@@ -47,14 +51,14 @@ export default {
});
});
},
- addToReview(note) {
+ addToReview(note, positionType = null) {
const lineRange =
(this.line && this.commentLineStart && formatLineRange(this.commentLineStart, this.line)) ||
{};
- const positionType = this.diffFileCommentForm
- ? IMAGE_DIFF_POSITION_TYPE
- : TEXT_DIFF_POSITION_TYPE;
- const selectedDiffFile = this.getDiffFileByHash(this.diffFileHash);
+ const position =
+ positionType ||
+ (this.diffFileCommentForm ? IMAGE_DIFF_POSITION_TYPE : TEXT_DIFF_POSITION_TYPE);
+ const diffFile = this.diffFile || this.file;
const postData = getDraftFormData({
note,
notesData: this.notesData,
@@ -62,23 +66,26 @@ export default {
noteableType: this.noteableType,
noteTargetLine: this.noteTargetLine,
diffViewType: this.diffViewType,
- diffFile: selectedDiffFile,
+ diffFile,
linePosition: this.position,
- positionType,
+ positionType: position,
...this.diffFileCommentForm,
lineRange,
+ showWhitespace: this.showWhitespace,
});
- const diffFileHeadSha = this.commit && this?.diffFile?.diff_refs?.head_sha;
+ const diffFileHeadSha = this.commit && diffFile?.diff_refs?.head_sha;
postData.data.note.commit_id = diffFileHeadSha || null;
return this.saveDraft(postData)
.then(() => {
- if (positionType === IMAGE_DIFF_POSITION_TYPE) {
+ if (position === IMAGE_DIFF_POSITION_TYPE) {
this.closeDiffFileCommentForm(this.diffFileHash);
- } else {
+ } else if (this.line?.line_code) {
this.handleClearForm(this.line.line_code);
+ } else if (position === FILE_DIFF_POSITION_TYPE) {
+ this.toggleFileCommentForm(diffFile.file_path);
}
})
.catch(() => {
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 90de7db8c1b..8e69f1ddc88 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -9,7 +9,7 @@ function getAllDiscussionElements() {
const containerEl = isOverviewPage() ? '.tab-pane.notes' : '.diffs';
return Array.from(
document.querySelectorAll(
- `${containerEl} div[data-discussion-id]:not([data-discussion-resolved])`,
+ `${containerEl} div[data-discussion-id][data-discussion-resolvable]:not([data-discussion-resolved])`,
),
);
}
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index dc7f1577bbb..1bb44988c4d 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -95,12 +95,19 @@ export const fetchDiscussions = (
{ commit, dispatch, getters },
{ path, filter, persistFilter },
) => {
- const config =
+ let config =
filter !== undefined
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
if (
+ window.gon?.features?.mrActivityFilters &&
+ getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
+ ) {
+ config = { params: { notes_filter: 0, persist_filter: false } };
+ }
+
+ if (
getters.noteableType === constants.ISSUE_NOTEABLE_TYPE ||
getters.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
) {
@@ -548,36 +555,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
return res;
};
- const processErrors = (error) => {
- if (error.response) {
- const {
- response: { data = {} },
- } = error;
- const { errors = {} } = data;
- const { base = [] } = errors;
-
- // we handle only errors.base for now
- if (base.length > 0) {
- const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), {
- error: base[0].toLowerCase(),
- });
- createAlert({
- message: errorMsg,
- parent: noteData.flashContainer,
- });
- return { ...data, hasAlert: true };
- }
- }
-
- throw error;
- };
-
return dispatch(methodToDispatch, postData, { root: true })
.then(processQuickActions)
.then(processEmojiAward)
.then(processTimeTracking)
- .then(removePlaceholder)
- .catch(processErrors);
+ .then(removePlaceholder);
};
export const setFetchingState = ({ commit }, fetchingState) =>
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 317fe6442d4..7eba491430b 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -1,58 +1,10 @@
-import { ASC, MR_FILTER_OPTIONS } from '../../constants';
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
+import createState from '../state';
export default () => ({
- state: {
- discussions: [],
- discussionSortOrder: ASC,
- persistSortOrder: true,
- convertedDisscussionIds: [],
- targetNoteHash: null,
- lastFetchedAt: null,
- currentDiscussionId: null,
- batchSuggestionsInfo: [],
- currentlyFetchingDiscussions: false,
- doneFetchingBatchDiscussions: false,
- /**
- * selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`:
- * {
- * start: { line_code: string, new_line: number, old_line:number, type: string },
- * end: { line_code: string, new_line: number, old_line:number, type: string },
- * }
- */
- selectedCommentPosition: null,
- selectedCommentPositionHover: null,
-
- // View layer
- isToggleStateButtonLoading: false,
- isNotesFetched: false,
- isLoading: true,
- isLoadingDescriptionVersion: false,
- isPromoteCommentToTimelineEventInProgress: false,
-
- // holds endpoints and permissions provided through haml
- notesData: {
- markdownDocsPath: '',
- },
- userData: {},
- noteableData: {
- discussion_locked: false,
- confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
- current_user: {},
- preview_note_path: 'path/to/preview',
- },
- isResolvingDiscussion: false,
- commentsDisabled: false,
- resolvableDiscussionsCount: 0,
- unresolvedDiscussionsCount: 0,
- descriptionVersions: {},
- isTimelineEnabled: false,
- isFetching: false,
- isPollingInitialized: false,
- mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value),
- },
+ state: createState(),
actions,
getters,
mutations,
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index c3407936847..a67928c387b 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,6 +1,7 @@
import { isEqual } from 'lodash';
import { STATUS_CLOSED, STATUS_REOPENED } from '~/issues/constants';
import { isInMRPage } from '~/lib/utils/common_utils';
+import { uuids } from '~/lib/utils/uuids';
import * as constants from '../constants';
import * as types from './mutation_types';
import * as utils from './utils';
@@ -82,7 +83,7 @@ export default {
const note = discussions[i];
const children = note.notes;
- if (children.length && !note.individual_note) {
+ if (children.length > 1) {
// remove placeholder from discussions
for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) {
@@ -185,6 +186,7 @@ export default {
}
notesArr.push({
+ id: uuids()[0],
individual_note: true,
isPlaceholderNote: true,
placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
diff --git a/app/assets/javascripts/notes/stores/state.js b/app/assets/javascripts/notes/stores/state.js
new file mode 100644
index 00000000000..8e49cd861a1
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/state.js
@@ -0,0 +1,53 @@
+import { ASC, MR_FILTER_OPTIONS } from '../constants';
+
+const createState = () => ({
+ discussions: [],
+ discussionSortOrder: ASC,
+ persistSortOrder: true,
+ convertedDisscussionIds: [],
+ targetNoteHash: null,
+ lastFetchedAt: null,
+ currentDiscussionId: null,
+ batchSuggestionsInfo: [],
+ currentlyFetchingDiscussions: false,
+ doneFetchingBatchDiscussions: false,
+ /**
+ * selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`:
+ * {
+ * start: { line_code: string, new_line: number, old_line:number, type: string },
+ * end: { line_code: string, new_line: number, old_line:number, type: string },
+ * }
+ */
+ selectedCommentPosition: null,
+ selectedCommentPositionHover: null,
+
+ // View layer
+ isToggleStateButtonLoading: false,
+ isNotesFetched: false,
+ isLoading: true,
+ isLoadingDescriptionVersion: false,
+ isPromoteCommentToTimelineEventInProgress: false,
+
+ // holds endpoints and permissions provided through haml
+ notesData: {
+ markdownDocsPath: '',
+ },
+ userData: {},
+ noteableData: {
+ discussion_locked: false,
+ confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
+ current_user: {},
+ preview_note_path: 'path/to/preview',
+ },
+ isResolvingDiscussion: false,
+ commentsDisabled: false,
+ resolvableDiscussionsCount: 0,
+ unresolvedDiscussionsCount: 0,
+ descriptionVersions: {},
+ isTimelineEnabled: false,
+ isFetching: false,
+ isPollingInitialized: false,
+ mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value),
+});
+
+export default createState;
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index ed1c80e7a6e..c5859a89182 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -2,6 +2,9 @@ import { marked } from 'marked';
import markedBidi from 'marked-bidi';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
+import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
+import { sprintf } from '~/locale';
+import { COMMENT_FORM } from './i18n';
/**
* Tracks snowplow event when User toggles timeline view
@@ -19,3 +22,17 @@ marked.use(markedBidi());
export const renderMarkdown = (rawMarkdown) => {
return sanitize(marked(rawMarkdown), markdownConfig);
};
+
+export const getErrorMessages = (data, status) => {
+ const errors = data?.errors;
+
+ if (errors && status === HTTP_STATUS_UNPROCESSABLE_ENTITY) {
+ if (errors.commands_only?.length) {
+ return errors.commands_only;
+ }
+
+ return [sprintf(COMMENT_FORM.error, { reason: errors.toLowerCase() }, false)];
+ }
+
+ return [COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK];
+};
diff --git a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue
deleted file mode 100644
index 7120ad511d3..00000000000
--- a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { s__ } from '~/locale';
-import { timezones } from '~/monitoring/format_date';
-
-export default {
- components: {
- GlFormGroup,
- GlFormSelect,
- },
- computed: {
- ...mapState(['dashboardTimezone']),
- dashboardTimezoneModel: {
- get() {
- return this.dashboardTimezone.selected;
- },
- set(selected) {
- this.setDashboardTimezone(selected);
- },
- },
- options() {
- return [
- {
- value: timezones.LOCAL,
- text: s__("MetricsSettings|User's local timezone"),
- },
- {
- value: timezones.UTC,
- text: s__('MetricsSettings|UTC (Coordinated Universal Time)'),
- },
- ];
- },
- },
- methods: {
- ...mapActions(['setDashboardTimezone']),
- },
-};
-</script>
-
-<template>
- <gl-form-group
- :label="s__('MetricsSettings|Dashboard timezone')"
- label-for="dashboard-timezone-setting"
- >
- <template #description>
- {{
- s__(
- "MetricsSettings|Choose whether to display dashboard metrics in UTC or the user's local timezone.",
- )
- }}
- </template>
-
- <gl-form-select
- id="dashboard-timezone-setting"
- v-model="dashboardTimezoneModel"
- :options="options"
- />
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue
deleted file mode 100644
index 2ea5b4e01b1..00000000000
--- a/app/assets/javascripts/operation_settings/components/form_group/external_dashboard.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-
-export default {
- components: {
- GlFormGroup,
- GlFormInput,
- },
- computed: {
- ...mapState(['externalDashboard']),
- userDashboardUrl: {
- get() {
- return this.externalDashboard.url;
- },
- set(url) {
- this.setExternalDashboardUrl(url);
- },
- },
- },
- methods: {
- ...mapActions(['setExternalDashboardUrl']),
- },
-};
-</script>
-
-<template>
- <gl-form-group
- :label="s__('MetricsSettings|External dashboard URL')"
- label-for="external-dashboard-url"
- >
- <template #description>
- {{
- s__(
- 'MetricsSettings|Add a button to the metrics dashboard linking directly to your existing external dashboard.',
- )
- }}
- </template>
- <!-- placeholder with a url is a false positive -->
- <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
- <gl-form-input
- id="external-dashboard-url"
- v-model="userDashboardUrl"
- placeholder="https://my-org.gitlab.io/my-dashboards"
- />
- <!-- eslint-enable @gitlab/vue-require-i18n-attribute-strings -->
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
deleted file mode 100644
index 959fffa2629..00000000000
--- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<script>
-import { GlButton, GlLink } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import DashboardTimezone from './form_group/dashboard_timezone.vue';
-import ExternalDashboard from './form_group/external_dashboard.vue';
-
-export default {
- components: {
- GlButton,
- GlLink,
- ExternalDashboard,
- DashboardTimezone,
- },
- computed: {
- ...mapState(['helpPage']),
- userDashboardUrl: {
- get() {
- return this.externalDashboard.url;
- },
- set(url) {
- this.setExternalDashboardUrl(url);
- },
- },
- },
- methods: {
- ...mapActions(['saveChanges']),
- },
-};
-</script>
-
-<template>
- <section class="settings no-animate">
- <div class="settings-header">
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
- {{ s__('MetricsSettings|Metrics') }}
- </h4>
- <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
- <p class="js-section-sub-header">
- {{ s__('MetricsSettings|Manage metrics dashboard settings.') }}
- <gl-link :href="helpPage">{{ __('Learn more.') }}</gl-link>
- </p>
- </div>
- <div class="settings-content">
- <form>
- <dashboard-timezone />
- <external-dashboard />
- <gl-button variant="confirm" category="primary" @click="saveChanges">
- {{ __('Save Changes') }}
- </gl-button>
- </form>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
deleted file mode 100644
index e56583963ad..00000000000
--- a/app/assets/javascripts/operation_settings/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import MetricsSettingsForm from './components/metrics_settings.vue';
-import store from './store';
-
-export default () => {
- const el = document.querySelector('.js-operation-settings');
-
- if (!el) return false;
-
- return new Vue({
- el,
- store: store(el.dataset),
- render(createElement) {
- return createElement(MetricsSettingsForm);
- },
- });
-};
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
deleted file mode 100644
index 7fa79da59c4..00000000000
--- a/app/assets/javascripts/operation_settings/store/actions.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import * as mutationTypes from './mutation_types';
-
-export const setExternalDashboardUrl = ({ commit }, url) =>
- commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url);
-
-export const setDashboardTimezone = ({ commit }, selected) =>
- commit(mutationTypes.SET_DASHBOARD_TIMEZONE, selected);
-
-export const saveChanges = ({ state, dispatch }) =>
- axios
- .patch(state.operationsSettingsEndpoint, {
- project: {
- metrics_setting_attributes: {
- dashboard_timezone: state.dashboardTimezone.selected,
- external_dashboard_url: state.externalDashboard.url,
- },
- },
- })
- .then(() => dispatch('receiveSaveChangesSuccess'))
- .catch((error) => dispatch('receiveSaveChangesError', error));
-
-export const receiveSaveChangesSuccess = () => {
- /**
- * The operations_controller currently handles successful requests
- * by creating an alert banner message to notify the user.
- */
- refreshCurrentPage();
-};
-
-export const receiveSaveChangesError = (_, error) => {
- const { response = {} } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- createAlert({
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
-};
diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js
deleted file mode 100644
index a11bd8089fd..00000000000
--- a/app/assets/javascripts/operation_settings/store/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-Vue.use(Vuex);
-
-export const createStore = (initialState) =>
- new Vuex.Store({
- state: createState(initialState),
- actions,
- mutations,
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js
deleted file mode 100644
index 92543fd7f03..00000000000
--- a/app/assets/javascripts/operation_settings/store/mutation_types.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL';
-export const SET_DASHBOARD_TIMEZONE = 'SET_DASHBOARD_TIMEZONE';
diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js
deleted file mode 100644
index f55717f6c98..00000000000
--- a/app/assets/javascripts/operation_settings/store/mutations.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.SET_EXTERNAL_DASHBOARD_URL](state, url) {
- state.externalDashboard.url = url;
- },
- [types.SET_DASHBOARD_TIMEZONE](state, selected) {
- state.dashboardTimezone.selected = selected;
- },
-};
diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js
deleted file mode 100644
index c0eca580848..00000000000
--- a/app/assets/javascripts/operation_settings/store/state.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default (initialState = {}) => ({
- operationsSettingsEndpoint: initialState.operationsSettingsEndpoint,
- helpPage: initialState.helpPage,
- externalDashboard: {
- url: initialState.externalDashboardUrl,
- },
- dashboardTimezone: {
- selected: initialState.dashboardTimezoneSetting,
- },
-});
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 7ac803a8ece..3a5992d182a 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -68,7 +68,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
export const CREATED_AT = s__('ContainerRegistry|Created %{time}');
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
-export const NOT_AVAILABLE_SIZE = __('0 bytes');
+export const NOT_AVAILABLE_SIZE = __('0 B');
export const CLEANUP_UNSCHEDULED_TEXT = s__('ContainerRegistry|Cleanup will run %{time}');
export const CLEANUP_SCHEDULED_TEXT = s__('ContainerRegistry|Cleanup pending');
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
index 5b4b85ec31e..ce98be914ae 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
@@ -16,7 +16,7 @@ export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}');
export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}');
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
-export const NOT_AVAILABLE_SIZE = __('0 bytes');
+export const NOT_AVAILABLE_SIZE = __('0 B');
export const TOKEN_TYPE_TAG_NAME = 'tag_name';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index e45b88bc6d5..ecd1bfb8ebe 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -1,5 +1,11 @@
<script>
-import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
+import {
+ GlLink,
+ GlTable,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
+ GlButton,
+} from '@gitlab/ui';
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
@@ -13,9 +19,8 @@ export default {
components: {
GlLink,
GlTable,
- GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
GlButton,
FileIcon,
TimeAgoTooltip,
@@ -136,14 +141,16 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <gl-dropdown category="tertiary" right>
- <template #button-content>
- <gl-icon name="ellipsis_v" />
- </template>
- <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)">
- {{ $options.i18n.deleteFile }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown category="tertiary" right no-caret icon="ellipsis_v">
+ <gl-disclosure-dropdown-item
+ data-testid="delete-file"
+ @action="$emit('delete-file', item)"
+ >
+ <template #list-item>
+ {{ $options.i18n.deleteFile }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
<template #row-details="{ item }">
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 6ea1fff9ef0..37fc326f902 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -81,7 +81,6 @@ export default {
const urlParams = new URLSearchParams(window.location.search);
const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
if (showAlert) {
- // to be refactored to use gl-alert
createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO });
const cleanUrl = window.location.href.split('?')[0];
historyReplaceState(cleanUrl);
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index 8eb8654cddd..3157653648b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -1,6 +1,17 @@
<script>
-import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
-import { last } from 'lodash';
+import {
+ GlAlert,
+ GlLink,
+ GlTable,
+ GlDropdownItem,
+ GlDropdown,
+ GlButton,
+ GlFormCheckbox,
+ GlLoadingIcon,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
@@ -9,69 +20,117 @@ import { packageTypeToTrackCategory } from '~/packages_and_registries/package_re
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ FETCH_PACKAGE_FILES_ERROR_MESSAGE,
+ GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
SELECT_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
TRACKING_LABEL_PACKAGE_ASSET,
TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
+ DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILES_TRACKING_ACTION,
+ DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
+ DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
} from '~/packages_and_registries/package_registry/constants';
+import getPackageFilesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql';
+import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
export default {
name: 'PackageFiles',
components: {
+ GlAlert,
GlLink,
GlTable,
GlDropdown,
GlDropdownItem,
GlFormCheckbox,
GlButton,
+ GlLoadingIcon,
+ GlModal,
+ GlSprintf,
FileIcon,
TimeAgoTooltip,
FileSha,
},
mixins: [Tracking.mixin()],
+ trackingActions: {
+ DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ },
props: {
canDelete: {
type: Boolean,
required: false,
default: false,
},
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
+ packageId: {
+ type: String,
+ required: true,
+ },
+ packageType: {
+ type: String,
+ required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
packageFiles: {
- type: Array,
- required: false,
- default: () => [],
+ query: getPackageFilesQuery,
+ context: {
+ isSingleRequest: true,
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.package?.packageFiles ?? {};
+ },
+ error() {
+ this.fetchPackageFilesError = true;
+ },
},
},
data() {
return {
+ fetchPackageFilesError: false,
+ filesToDelete: [],
+ packageFiles: {},
+ mutationLoading: false,
selectedReferences: [],
};
},
computed: {
+ files() {
+ return this.packageFiles?.nodes ?? [];
+ },
areFilesSelected() {
return this.selectedReferences.length > 0;
},
areAllFilesSelected() {
- return this.packageFiles.every(this.isSelected);
+ return this.files.length > 0 && this.files.every(this.isSelected);
},
filesTableRows() {
- return this.packageFiles.map((pf) => ({
+ return this.files.map((pf) => ({
...pf,
size: this.formatSize(pf.size),
- pipeline: last(pf.pipelines),
}));
},
hasSelectedSomeFiles() {
return this.areFilesSelected && !this.areAllFilesSelected;
},
- showCommitColumn() {
- // note that this is always false for now since we do not return
- // pipelines associated to files for performance concerns
- return this.filesTableRows.some((row) => Boolean(row.pipeline?.id));
+ isLoading() {
+ return this.$apollo.queries.packageFiles.loading || this.mutationLoading;
},
filesTableHeaderFields() {
return [
@@ -86,11 +145,6 @@ export default {
label: __('Name'),
},
{
- key: 'commit',
- label: __('Commit'),
- hide: !this.showCommitColumn,
- },
- {
key: 'size',
label: __('Size'),
},
@@ -108,11 +162,40 @@ export default {
},
].filter((c) => !c.hide);
},
+ queryVariables() {
+ return {
+ id: this.packageId,
+ first: GRAPHQL_PACKAGE_FILES_PAGE_SIZE,
+ };
+ },
tracking() {
return {
category: packageTypeToTrackCategory(this.packageType),
};
},
+ refetchQueriesData() {
+ return [
+ {
+ query: getPackageFilesQuery,
+ variables: this.queryVariables,
+ },
+ ];
+ },
+ modalAction() {
+ return this.hasOneItem(this.filesToDelete)
+ ? this.$options.modal.fileDeletePrimaryAction
+ : this.$options.modal.filesDeletePrimaryAction;
+ },
+ modalTitle() {
+ return this.hasOneItem(this.filesToDelete)
+ ? this.$options.i18n.deleteFileModalTitle
+ : this.$options.i18n.deleteFilesModalTitle;
+ },
+ modalDescription() {
+ return this.hasOneItem(this.filesToDelete)
+ ? this.$options.i18n.deleteFileModalContent
+ : this.$options.i18n.deleteFilesModalContent;
+ },
},
methods: {
formatSize(size) {
@@ -135,13 +218,96 @@ export default {
},
handleFileDeleteSelected() {
this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION);
- this.$emit('delete-files', this.selectedReferences);
+ this.handleFileDelete(this.selectedReferences);
+ },
+ async deletePackageFiles(ids) {
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: destroyPackageFilesMutation,
+ variables: {
+ projectPath: this.projectPath,
+ ids,
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: this.refetchQueriesData,
+ });
+ if (data?.destroyPackageFiles?.errors[0]) {
+ throw data.destroyPackageFiles.errors[0];
+ }
+ createAlert({
+ message: this.hasOneItem(ids)
+ ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
+ : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ variant: VARIANT_SUCCESS,
+ });
+ } catch (error) {
+ createAlert({
+ message: this.hasOneItem(ids)
+ ? DELETE_PACKAGE_FILE_ERROR_MESSAGE
+ : DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ variant: VARIANT_WARNING,
+ captureError: true,
+ error,
+ });
+ } finally {
+ this.mutationLoading = false;
+ this.filesToDelete = [];
+ this.selectedReferences = [];
+ }
+ },
+ handleFileDelete(files) {
+ this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
+ if (files.length === this.files.length && !this.packageFiles?.pageInfo?.hasNextPage) {
+ this.$emit(
+ 'delete-all-files',
+ this.hasOneItem(files)
+ ? DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT
+ : DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
+ );
+ } else {
+ this.filesToDelete = files;
+ this.$refs.deleteFilesModal.show();
+ }
+ },
+ hasOneItem(items) {
+ return items.length === 1;
+ },
+ confirmFilesDelete() {
+ if (this.hasOneItem(this.filesToDelete)) {
+ this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
+ }
+ this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
},
},
i18n: {
- deleteFile: __('Delete asset'),
+ deleteFile: s__('PackageRegistry|Delete asset'),
+ deleteFileModalTitle: s__('PackageRegistry|Delete package asset'),
+ deleteFileModalContent: s__(
+ 'PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?',
+ ),
+ deleteFilesModalTitle: s__('PackageRegistry|Delete %{count} assets'),
+ deleteFilesModalContent: s__(
+ 'PackageRegistry|You are about to delete %{count} assets. This operation is irreversible.',
+ ),
deleteSelected: s__('PackageRegistry|Delete selected'),
moreActionsText: __('More actions'),
+ fetchPackageFilesErrorMessage: FETCH_PACKAGE_FILES_ERROR_MESSAGE,
+ },
+ modal: {
+ fileDeletePrimaryAction: {
+ text: __('Delete'),
+ attributes: { variant: 'danger', category: 'primary' },
+ },
+ filesDeletePrimaryAction: {
+ text: s__('PackageRegistry|Permanently delete assets'),
+ attributes: { variant: 'danger', category: 'primary' },
+ },
+ cancelAction: {
+ text: __('Cancel'),
+ },
},
};
</script>
@@ -151,7 +317,7 @@ export default {
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
<h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-button
- v-if="canDelete"
+ v-if="!fetchPackageFilesError && canDelete"
:disabled="isLoading || !areFilesSelected"
category="secondary"
variant="danger"
@@ -161,7 +327,16 @@ export default {
{{ $options.i18n.deleteSelected }}
</gl-button>
</div>
+ <gl-alert
+ v-if="fetchPackageFilesError"
+ variant="danger"
+ @dismiss="fetchPackageFilesError = false"
+ >
+ {{ $options.i18n.fetchPackageFilesErrorMessage }}
+ </gl-alert>
<gl-table
+ v-else
+ :busy="isLoading"
:fields="filesTableHeaderFields"
:items="filesTableRows"
show-empty
@@ -171,6 +346,9 @@ export default {
:tbody-tr-attr="{ 'data-testid': 'file-row' }"
@row-selected="updateSelectedReferences"
>
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
<template #head(checkbox)="{ selectAllRows, clearSelected }">
<gl-form-checkbox
v-if="canDelete"
@@ -207,7 +385,7 @@ export default {
:href="item.downloadPath"
class="gl-text-gray-500"
data-testid="download-link"
- @click="$emit('download-file')"
+ @click="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
>
<file-icon
:file-name="item.fileName"
@@ -218,16 +396,6 @@ export default {
</gl-link>
</template>
- <template #cell(commit)="{ item }">
- <gl-link
- v-if="item.pipeline && item.pipeline"
- :href="item.pipeline.commitPath"
- class="gl-text-gray-500"
- data-testid="commit-link"
- >{{ item.pipeline.sha }}
- </gl-link>
- </template>
-
<template #cell(created)="{ item }">
<time-ago-tooltip :time="item.createdAt" />
</template>
@@ -241,7 +409,7 @@ export default {
no-caret
right
>
- <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])">
+ <gl-dropdown-item data-testid="delete-file" @click="handleFileDelete([item])">
{{ $options.i18n.deleteFile }}
</gl-dropdown-item>
</gl-dropdown>
@@ -262,5 +430,34 @@ export default {
</div>
</template>
</gl-table>
+
+ <gl-modal
+ ref="deleteFilesModal"
+ size="sm"
+ modal-id="delete-files-modal"
+ :action-primary="modalAction"
+ :action-cancel="$options.modal.cancelAction"
+ data-testid="delete-files-modal"
+ @primary="confirmFilesDelete"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="modalTitle">
+ <template #count>
+ {{ filesToDelete.length }}
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <gl-sprintf :message="modalDescription">
+ <template #filename>
+ <strong>{{ filesToDelete[0].fileName }}</strong>
+ </template>
+
+ <template #count>
+ {{ filesToDelete.length }}
+ </template>
+ </gl-sprintf>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
index cee976656f9..5eabcea9e15 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
@@ -1,7 +1,6 @@
<script>
import { GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
@@ -61,13 +60,6 @@ export default {
hasTagsToDisplay() {
return Boolean(this.packageEntity.tags?.nodes && this.packageEntity.tags?.nodes.length);
},
- totalSize() {
- return this.packageEntity.packageFiles
- ? numberToHumanSize(
- this.packageEntity.packageFiles.nodes.reduce((acc, p) => acc + Number(p.size), 0),
- )
- : '0';
- },
},
mounted() {
this.checkBreakpoints();
@@ -126,10 +118,6 @@ export default {
<metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" />
</template>
- <template #metadata-size>
- <metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
- </template>
-
<template v-if="isGroupPage && packagePipeline" #metadata-pipeline>
<metadata-item
data-testid="pipeline-project"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index b4276d69ed6..80712c2991c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -102,6 +102,9 @@ export const FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE = s__(
export const FETCH_PACKAGE_METADATA_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while fetching the package metadata.',
);
+export const FETCH_PACKAGE_FILES_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while fetching package assets.',
+);
export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages';
export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages';
@@ -232,3 +235,4 @@ export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
);
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
+export const GRAPHQL_PACKAGE_FILES_PAGE_SIZE = 100;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index 39e5da54509..d05ff5daad4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -21,6 +21,9 @@ export const apolloProvider = new VueApollo({
keyArgs: false,
merge: mergeVariables,
},
+ packageFiles: {
+ merge: mergeVariables,
+ },
},
},
},
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 984996b829a..4c71de9ee20 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -46,21 +46,6 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
}
- packageFiles(first: 100) {
- pageInfo {
- hasNextPage
- }
- nodes {
- id
- fileMd5
- fileName
- fileSha1
- fileSha256
- size
- createdAt
- downloadPath
- }
- }
versions {
count
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql
new file mode 100644
index 00000000000..e6f292ec1d3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql
@@ -0,0 +1,20 @@
+query getPackageFiles($id: PackagesPackageID!, $first: Int) {
+ package(id: $id) {
+ id
+ packageFiles(first: $first) {
+ pageInfo {
+ hasNextPage
+ }
+ nodes {
+ id
+ fileMd5
+ fileName
+ fileSha1
+ fileSha256
+ size
+ createdAt
+ downloadPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 6d4979ac785..d96418571e1 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -11,7 +11,7 @@ import {
GlTabs,
GlSprintf,
} from '@gitlab/ui';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import { createAlert } from '~/alert';
import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -21,10 +21,8 @@ import { packageTypeToTrackCategory } from '~/packages_and_registries/package_re
import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue';
-import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
-import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import {
PACKAGE_TYPE_NUGET,
@@ -35,27 +33,15 @@ import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
- DELETE_PACKAGE_FILE_TRACKING_ACTION,
- DELETE_PACKAGE_FILES_TRACKING_ACTION,
- REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
REQUEST_FORWARDING_HELP_PAGE_PATH,
- CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
- DELETE_PACKAGE_FILE_ERROR_MESSAGE,
- DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
- DELETE_PACKAGE_FILES_ERROR_MESSAGE,
- DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
- DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
DELETE_MODAL_TITLE,
DELETE_MODAL_CONTENT,
- DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
- DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
GRAPHQL_PAGE_SIZE,
} from '~/packages_and_registries/package_registry/constants';
-import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
import Tracking from '~/tracking';
@@ -76,9 +62,13 @@ export default {
PackageHistory,
AdditionalMetadata,
InstallationCommands,
- PackageFiles,
+ PackageFiles: () =>
+ import('~/packages_and_registries/package_registry/components/details/package_files.vue'),
DeletePackages,
- PackageVersionsList,
+ PackageVersionsList: () =>
+ import(
+ '~/packages_and_registries/package_registry/components/details/package_versions_list.vue'
+ ),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -90,10 +80,6 @@ export default {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
- DELETE_PACKAGE_FILE_TRACKING_ACTION,
- REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
- CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
- DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
},
data() {
return {
@@ -147,18 +133,12 @@ export default {
id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
};
},
- packageFiles() {
- return this.packageEntity.packageFiles?.nodes;
- },
packageType() {
return this.packageEntity.packageType;
},
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
- packageFilesLoading() {
- return this.isLoading || this.mutationLoading;
- },
isValidPackage() {
return this.isLoading || Boolean(this.packageEntity.name);
},
@@ -194,14 +174,6 @@ export default {
PACKAGE_TYPE_PYPI,
].includes(this.packageType);
},
- refetchQueriesData() {
- return [
- {
- query: getPackageDetails,
- variables: this.queryVariables,
- },
- ];
- },
refetchVersionsQueryData() {
return [
{
@@ -228,71 +200,9 @@ export default {
window.location.replace(`${returnTo}?${modalQuery}`);
},
- async deletePackageFiles(ids) {
- this.mutationLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: destroyPackageFilesMutation,
- variables: {
- projectPath: this.projectPath,
- ids,
- },
- awaitRefetchQueries: true,
- refetchQueries: this.refetchQueriesData,
- });
- if (data?.destroyPackageFiles?.errors[0]) {
- throw data.destroyPackageFiles.errors[0];
- }
- createAlert({
- message: this.isLastItem(ids)
- ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
- : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
- variant: VARIANT_SUCCESS,
- });
- } catch (error) {
- createAlert({
- message: this.isLastItem(ids)
- ? DELETE_PACKAGE_FILE_ERROR_MESSAGE
- : DELETE_PACKAGE_FILES_ERROR_MESSAGE,
- variant: VARIANT_WARNING,
- captureError: true,
- error,
- });
- }
- this.mutationLoading = false;
- },
- handleFileDelete(files) {
- this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
- if (
- files.length === this.packageFiles.length &&
- !this.packageEntity.packageFiles?.pageInfo?.hasNextPage
- ) {
- if (this.isLastItem(files)) {
- this.deletePackageModalContent = DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT;
- } else {
- this.deletePackageModalContent = DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT;
- }
- this.$refs.deleteModal.show();
- } else {
- this.filesToDelete = files;
- if (this.isLastItem(files)) {
- this.$refs.deleteFileModal.show();
- } else if (files.length > 1) {
- this.$refs.deleteFilesModal.show();
- }
- }
- },
- isLastItem(items) {
- return items.length === 1;
- },
- confirmFilesDelete() {
- if (this.isLastItem(this.filesToDelete)) {
- this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
- } else {
- this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
- }
- this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
- this.filesToDelete = [];
+ handleAllFilesDelete(content) {
+ this.deletePackageModalContent = content;
+ this.$refs.deleteModal.show();
},
resetDeleteModalContent() {
this.deletePackageModalContent = DELETE_MODAL_CONTENT;
@@ -300,10 +210,6 @@ export default {
},
i18n: {
DELETE_MODAL_TITLE,
- deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
- deleteFileModalContent: s__(
- `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
- ),
otherVersionsTabTitle: s__('PackageRegistry|Other versions'),
},
links: {
@@ -358,7 +264,7 @@ export default {
<gl-tabs>
<gl-tab :title="__('Detail')">
- <div v-if="!isLoading" data-qa-selector="package_information_content">
+ <div data-qa-selector="package_information_content">
<package-history :package-entity="packageEntity" :project-name="projectName" />
<installation-commands :package-entity="packageEntity" />
@@ -368,16 +274,16 @@ export default {
:package-id="packageEntity.id"
:package-type="packageType"
/>
- </div>
- <package-files
- v-if="showFiles"
- :can-delete="packageEntity.canDestroy"
- :is-loading="packageFilesLoading"
- :package-files="packageFiles"
- @download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
- @delete-files="handleFileDelete"
- />
+ <package-files
+ v-if="showFiles"
+ :can-delete="packageEntity.canDestroy"
+ :package-id="packageEntity.id"
+ :package-type="packageType"
+ :project-path="projectPath"
+ @delete-all-files="handleAllFilesDelete"
+ />
+ </div>
</gl-tab>
<gl-tab v-if="showDependencies">
@@ -468,51 +374,5 @@ export default {
</gl-modal>
</template>
</delete-packages>
-
- <gl-modal
- ref="deleteFileModal"
- size="sm"
- modal-id="delete-file-modal"
- :action-primary="$options.modal.fileDeletePrimaryAction"
- :action-cancel="$options.modal.cancelAction"
- data-testid="delete-file-modal"
- @primary="confirmFilesDelete"
- @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
- >
- <template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
- <gl-sprintf v-if="isLastItem(filesToDelete)" :message="$options.i18n.deleteFileModalContent">
- <template #filename>
- <strong>{{ filesToDelete[0].fileName }}</strong>
- </template>
- </gl-sprintf>
- </gl-modal>
-
- <gl-modal
- ref="deleteFilesModal"
- size="sm"
- modal-id="delete-files-modal"
- :action-primary="$options.modal.filesDeletePrimaryAction"
- :action-cancel="$options.modal.cancelAction"
- data-testid="delete-files-modal"
- @primary="confirmFilesDelete"
- @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
- >
- <template #modal-title>{{
- n__(
- `PackageRegistry|Delete 1 asset`,
- `PackageRegistry|Delete %d assets`,
- filesToDelete.length,
- )
- }}</template>
- <span v-if="filesToDelete.length > 0">
- {{
- n__(
- `PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`,
- `PackageRegistry|You are about to delete %d assets. This operation is irreversible.`,
- filesToDelete.length,
- )
- }}
- </span>
- </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 044ce4e6413..14d617a7a3c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -114,7 +114,6 @@ export default {
const urlParams = new URLSearchParams(window.location.search);
const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
if (showAlert) {
- // to be refactored to use gl-alert
createAlert({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, variant: VARIANT_INFO });
const cleanUrl = window.location.href.split('?')[0];
historyReplaceState(cleanUrl);
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
index f95ec4336dc..80df8ef81e6 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -139,7 +139,7 @@ export default {
:form-options="$options.formOptions.keepNDuplicatedPackageFiles"
:label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
:description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
- dropdown-class="gl-md-max-w-50p gl-sm-pr-5"
+ dropdown-class="gl-md-max-w-50p"
name="keep-n-duplicated-package-files"
data-testid="keep-n-duplicated-package-files-dropdown"
@input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js
index 524b2c6f66a..82051507276 100644
--- a/app/assets/javascripts/pages/admin/clusters/show/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/show/index.js
@@ -1,7 +1,5 @@
import ClustersBundle from '~/clusters/clusters_bundle';
import initIntegrationForm from '~/clusters/forms/show';
-import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
new ClustersBundle(); // eslint-disable-line no-new
-initClusterHealth();
initIntegrationForm();
diff --git a/app/assets/javascripts/pages/admin/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js
index 8c4ea2cde92..4af8cb355fc 100644
--- a/app/assets/javascripts/pages/admin/jobs/components/constants.js
+++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js
@@ -1,5 +1,5 @@
import { s__, __ } from '~/locale';
-import { DEFAULT_FIELDS, RAW_TEXT_WARNING } from '~/jobs/components/table/constants';
+import { RAW_TEXT_WARNING } from '~/jobs/components/table/constants';
export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.');
export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.');
@@ -19,11 +19,17 @@ export const RUNNER_EMPTY_TEXT = __('None');
export const RUNNER_NO_DESCRIPTION = s__('Runners|No description');
/* Admin Table constants */
+/* The field list is based on app/assets/javascripts/jobs/components/table/constants.js */
export const DEFAULT_FIELDS_ADMIN = [
- ...DEFAULT_FIELDS.slice(0, 2),
+ { key: 'status', label: __('Status'), columnClass: 'gl-w-15p' },
+ { key: 'job', label: __('Job'), columnClass: 'gl-w-20p' },
{ key: 'project', label: __('Project'), columnClass: 'gl-w-20p' },
{ key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' },
- ...DEFAULT_FIELDS.slice(2),
+ { key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' },
+ { key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' },
+ { key: 'name', label: __('Name'), columnClass: 'gl-w-15p' },
+ { key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' },
+ { key: 'actions', label: '', columnClass: 'gl-w-10p' },
];
export const RAW_TEXT_WARNING_ADMIN = RAW_TEXT_WARNING;
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index b2cbd52fb27..901fd9193a5 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -1,11 +1,10 @@
-import $ from 'jquery';
-import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
import { initRemoveAvatar } from '~/admin/topics';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
-new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
new ZenMode(); // eslint-disable-line no-new
initRemoveAvatar();
+mountMarkdownEditor();
diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js
index c4e05bbd092..fc9ca4fd4e6 100644
--- a/app/assets/javascripts/pages/admin/topics/new/index.js
+++ b/app/assets/javascripts/pages/admin/topics/new/index.js
@@ -1,8 +1,7 @@
-import $ from 'jquery';
-import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
-new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
new ZenMode(); // eslint-disable-line no-new
+mountMarkdownEditor();
diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js
index 5d202a8824f..487e7a14a16 100644
--- a/app/assets/javascripts/pages/groups/clusters/show/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/show/index.js
@@ -1,5 +1,3 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-import initClusterHealth from '~/pages/projects/clusters/show/cluster_health';
new ClustersBundle(); // eslint-disable-line no-new
-initClusterHealth();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 2e71eced66f..df6ca8eab96 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -1,8 +1,6 @@
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants';
@@ -60,7 +58,5 @@ const APP_OPTIONS = {
initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS);
-initInviteMembersModal();
initInviteGroupsModal();
-initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 513f4968dbd..3ee15077d00 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -1,6 +1,6 @@
<script>
-import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg';
-import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg';
+import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg?raw';
+import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg?raw';
import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
@@ -39,6 +39,11 @@ export default {
required: false,
default: false,
},
+ isSaas: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
initialBreadcrumbs() {
@@ -93,6 +98,7 @@ export default {
:initial-breadcrumbs="initialBreadcrumbs"
:panels="panels"
:title="s__('GroupsNew|Create new group')"
+ :is-saas="isSaas"
persistence-key="new_group_last_active_tab"
/>
</template>
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 2e53324717c..84e031ae67a 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -27,6 +27,7 @@ function initNewGroupCreation(el) {
parentGroupUrl,
parentGroupName,
importExistingGroupPath,
+ isSaas,
} = el.dataset;
const props = {
@@ -36,6 +37,7 @@ function initNewGroupCreation(el) {
parentGroupName,
importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
+ isSaas: parseBoolean(isSaas),
};
const apolloProvider = new VueApollo({
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index dba65c7e791..5d9eafe5672 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,6 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
@@ -12,5 +11,4 @@ export default function initGroupDetails() {
new ProjectsList(); // eslint-disable-line no-new
initInviteMembersBanner();
- initInviteMembersModal();
}
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index 96ea7329e6e..69457adf94e 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,8 +1,11 @@
import emojiRegex from 'emoji-regex';
import { __ } from '~/locale';
import { initSetStatusForm } from '~/profile/profile';
+import { initProfileEdit } from '~/profile/edit';
initSetStatusForm();
+// It will do nothing for now when the feature flag is turned off
+initProfileEdit();
const userNameInput = document.getElementById('user_name');
if (userNameInput) {
diff --git a/app/assets/javascripts/pages/profiles/slacks/index.js b/app/assets/javascripts/pages/profiles/slacks/index.js
new file mode 100644
index 00000000000..4066d0046ae
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/slacks/index.js
@@ -0,0 +1,3 @@
+import initGitlabSlackApplication from '~/integrations/gitlab_slack_application';
+
+initGitlabSlackApplication();
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index ac5e0b28dd1..f5cd03ac48d 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -1,9 +1,9 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
-import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
import initDeleteMergedBranches from '~/branches/init_delete_merged_branches';
+import initBranchMoreActions from '~/branches/init_branch_more_actions';
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
@@ -14,8 +14,6 @@ BranchSortDropdown();
initDeprecatedRemoveRowBehavior();
initDeleteMergedBranches();
-document
- .querySelectorAll('.js-delete-branch-button')
- .forEach((elem) => initDeleteBranchButton(elem));
+document.querySelectorAll('.js-branch-more-actions').forEach((elem) => initBranchMoreActions(elem));
initDeleteBranchModal();
diff --git a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js
deleted file mode 100644
index 382d39645a9..00000000000
--- a/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import monitoringApp from '~/monitoring/monitoring_app';
-
-export default () => {
- const el = document.getElementById('prometheus-graphs');
-
- if (el && el.dataset) {
- monitoringApp({
- ...el.dataset,
- showLegend: false,
- showHeader: false,
- showPanels: false,
- forceSmallGraph: true,
- smallEmptyState: true,
- currentEnvironmentName: '',
- hasMetrics: true,
- });
- }
-};
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
index 0b34f374abc..5c5402a14b1 100644
--- a/app/assets/javascripts/pages/projects/clusters/show/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -1,9 +1,7 @@
import ClustersBundle from '~/clusters/clusters_bundle';
import initIntegrationForm from '~/clusters/forms/show';
import initGkeNamespace from '~/clusters/gke_cluster_namespace';
-import initClusterHealth from './cluster_health';
new ClustersBundle(); // eslint-disable-line no-new
initGkeNamespace();
-initClusterHealth();
initIntegrationForm();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 38cc4337047..67dc3782a24 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,7 +1,9 @@
import mountNotesApp from 'ee_else_ce/mr_notes/mount_app';
import { initReportAbuse } from '~/projects/report_abuse';
+import { initMrMoreDropdown } from '~/mr_more_dropdown';
import { initMrPage } from '../page';
initMrPage();
mountNotesApp();
initReportAbuse();
+initMrMoreDropdown();
diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js
index d3f46b7e025..44a384f03c6 100644
--- a/app/assets/javascripts/pages/projects/pipelines/show/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js
@@ -2,4 +2,4 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
import initPipelines from '../init_pipelines';
initPipelines();
-initPipelineDetails();
+initPipelineDetails(gon.features.pipelineDetailsHeaderVue);
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 79a4ed0f9c3..1e9111a3cc6 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,9 +1,7 @@
import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger';
import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import { initMembersApp } from '~/members';
import { MEMBER_TYPES } from '~/members/constants';
@@ -11,9 +9,7 @@ import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
initImportProjectMembersModal();
-initInviteMembersModal();
initInviteGroupsModal();
-initInviteMembersTrigger();
initInviteGroupTrigger();
initImportProjectMembersTrigger();
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 731b1373987..b2681267e06 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
@@ -2,6 +2,7 @@ import initArtifactsSettings from '~/artifacts_settings';
import SecretValues from '~/behaviors/secret_values';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
import initVariableList from '~/ci/ci_variable_list';
+import initInheritedGroupCiVariables from '~/ci/inherited_ci_variables';
import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
@@ -26,6 +27,7 @@ if (runnerToken) {
}
initVariableList();
+initInheritedGroupCiVariables();
// hide extra auto devops settings based checkbox state
const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings');
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 3a46241e2eb..1b8657c5ec7 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -1,14 +1,10 @@
import mountAlertsSettings from '~/alerts_settings';
import mountErrorTrackingForm from '~/error_tracking_settings';
-import mountGrafanaIntegration from '~/grafana_integration';
import initIncidentsSettings from '~/incidents_settings';
-import mountOperationSettings from '~/operation_settings';
import initSettingsPanels from '~/settings_panels';
initIncidentsSettings();
mountErrorTrackingForm();
-mountOperationSettings();
-mountGrafanaIntegration();
if (!IS_EE) {
initSettingsPanels();
}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue
new file mode 100644
index 00000000000..ed5ba3c2653
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/ci_catalog_settings.vue
@@ -0,0 +1,165 @@
+<script>
+import { GlBadge, GlLink, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui';
+import { createAlert, VARIANT_INFO } from '~/alert';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+import getCiCatalogSettingsQuery from '../graphql/queries/get_ci_catalog_settings.query.graphql';
+import catalogResourcesCreate from '../graphql/mutations/catalog_resources_create.mutation.graphql';
+
+export const i18n = {
+ badgeText: __('Experiment'),
+ catalogResourceQueryError: s__(
+ 'CiCatalog|There was a problem fetching the CI/CD Catalog setting.',
+ ),
+ catalogResourceMutationError: s__(
+ 'CiCatalog|There was a problem marking the project as a CI/CD Catalog resource.',
+ ),
+ catalogResourceMutationSuccess: s__('CiCatalog|This project is now a CI/CD Catalog resource.'),
+ ciCatalogLabel: s__('CiCatalog|CI/CD Catalog resource'),
+ ciCatalogHelpText: s__(
+ 'CiCatalog|Mark project as a CI/CD Catalog resource. %{linkStart}What is the CI/CD Catalog?%{linkEnd}',
+ ),
+ modal: {
+ actionPrimary: {
+ text: s__('CiCatalog|Mark project as a CI/CD Catalog resource'),
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ body: s__(
+ 'CiCatalog|This project will be marked as a CI/CD Catalog resource and will be visible in the CI/CD Catalog. This action is not reversible.',
+ ),
+ title: s__('CiCatalog|Mark project as a CI/CD Catalog resource'),
+ },
+ readMeHelpText: s__(
+ 'CiCatalog|The project must contain a README.md file and a template.yml file. When enabled, the repository is available in the CI/CD Catalog.',
+ ),
+};
+
+export const ciCatalogHelpPath = helpPagePath('ci/components/index', {
+ anchor: 'components-catalog',
+});
+
+export default {
+ i18n,
+ components: {
+ GlBadge,
+ GlLink,
+ GlLoadingIcon,
+ GlModal,
+ GlSprintf,
+ GlToggle,
+ },
+ props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ ciCatalogHelpPath,
+ isCatalogResource: false,
+ showCatalogResourceModal: false,
+ };
+ },
+ apollo: {
+ isCatalogResource: {
+ query: getCiCatalogSettingsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({ project }) {
+ return project?.isCatalogResource || false;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.catalogResourceQueryError });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.isCatalogResource.loading;
+ },
+ },
+ methods: {
+ async markProjectAsCatalogResource() {
+ try {
+ const {
+ data: {
+ catalogResourcesCreate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: catalogResourcesCreate,
+ variables: { input: { projectPath: this.fullPath } },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+
+ this.isCatalogResource = true;
+ createAlert({
+ message: this.$options.i18n.catalogResourceMutationSuccess,
+ variant: VARIANT_INFO,
+ });
+ } catch (error) {
+ const message = error.message || this.$options.i18n.catalogResourceMutationError;
+ createAlert({ message });
+ }
+ },
+ onCatalogResourceEnabledToggled() {
+ this.showCatalogResourceModal = true;
+ },
+ onModalCanceled() {
+ this.showCatalogResourceModal = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" />
+ <div v-else data-testid="ci-catalog-settings">
+ <div>
+ <label class="gl-mb-1 gl-mr-2">
+ {{ $options.i18n.ciCatalogLabel }}
+ </label>
+ <gl-badge size="sm" variant="info"> {{ $options.i18n.badgeText }} </gl-badge>
+ </div>
+ <gl-sprintf :message="$options.i18n.ciCatalogHelpText">
+ <template #link="{ content }">
+ <gl-link :href="ciCatalogHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-toggle
+ class="gl-my-2"
+ :disabled="isCatalogResource"
+ :value="isCatalogResource"
+ :label="$options.i18n.ciCatalogLabel"
+ label-position="hidden"
+ name="ci_resource_enabled"
+ @change="onCatalogResourceEnabledToggled"
+ />
+ <div class="gl-text-secondary">
+ {{ $options.i18n.readMeHelpText }}
+ </div>
+ <gl-modal
+ :visible="showCatalogResourceModal"
+ modal-id="mark-as-catalog-resource"
+ size="sm"
+ :title="$options.i18n.modal.title"
+ :action-cancel="$options.i18n.modal.actionCancel"
+ :action-primary="$options.i18n.modal.actionPrimary"
+ @canceled="onModalCanceled"
+ @primary="markProjectAsCatalogResource"
+ >
+ {{ $options.i18n.modal.body }}
+ </gl-modal>
+ </div>
+ </div>
+</template>
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 64c363dd721..c54596488af 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
@@ -2,7 +2,6 @@
import { GlButton, GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import {
VISIBILITY_LEVEL_PRIVATE_INTEGER,
@@ -16,10 +15,12 @@ import {
featureAccessLevel,
CVE_ID_REQUEST_BUTTON_I18N,
featureAccessLevelDescriptions,
+ modelExperimentsHelpPath,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
import ProjectFeatureSetting from './project_feature_setting.vue';
import ProjectSettingRow from './project_setting_row.vue';
+import CiCatalogSettings from './ci_catalog_settings.vue';
const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')];
@@ -34,6 +35,7 @@ export default {
...CVE_ID_REQUEST_BUTTON_I18N,
analyticsLabel: s__('ProjectSettings|Analytics'),
containerRegistryLabel: s__('ProjectSettings|Container registry'),
+ ciCdLabel: __('CI/CD'),
forksLabel: s__('ProjectSettings|Forks'),
issuesLabel: s__('ProjectSettings|Issues'),
lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
@@ -57,8 +59,11 @@ export default {
packageRegistryForEveryoneLabel: s__(
'ProjectSettings|Allow anyone to pull from Package Registry',
),
+ modelExperimentsLabel: s__('ProjectSettings|Model experiments'),
+ modelExperimentsHelpText: s__(
+ 'ProjectSettings|Track machine learning model experiments and artifacts.',
+ ),
pagesLabel: s__('ProjectSettings|Pages'),
- ciCdLabel: __('CI/CD'),
repositoryLabel: s__('ProjectSettings|Repository'),
requirementsLabel: s__('ProjectSettings|Requirements'),
releasesLabel: s__('ProjectSettings|Releases'),
@@ -77,8 +82,10 @@ export default {
VISIBILITY_LEVEL_PRIVATE_INTEGER,
VISIBILITY_LEVEL_INTERNAL_INTEGER,
VISIBILITY_LEVEL_PUBLIC_INTEGER,
+ modelExperimentsHelpPath,
components: {
+ CiCatalogSettings,
ProjectFeatureSetting,
ProjectSettingRow,
GlButton,
@@ -93,7 +100,7 @@ export default {
'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
),
},
- mixins: [settingsMixin, glFeatureFlagsMixin()],
+ mixins: [settingsMixin],
props: {
requestCveAvailable: {
@@ -101,6 +108,11 @@ export default {
required: false,
default: false,
},
+ canAddCatalogResource: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
currentSettings: {
type: Object,
required: true,
@@ -246,11 +258,11 @@ export default {
forkingAccessLevel: featureAccessLevel.EVERYONE,
mergeRequestsAccessLevel: featureAccessLevel.EVERYONE,
packageRegistryAccessLevel: featureAccessLevel.EVERYONE,
+ modelExperimentsAccessLevel: featureAccessLevel.EVERYONE,
buildsAccessLevel: featureAccessLevel.EVERYONE,
wikiAccessLevel: featureAccessLevel.EVERYONE,
snippetsAccessLevel: featureAccessLevel.EVERYONE,
pagesAccessLevel: featureAccessLevel.EVERYONE,
- metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
analyticsAccessLevel: featureAccessLevel.EVERYONE,
requirementsAccessLevel: featureAccessLevel.EVERYONE,
securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
@@ -392,15 +404,15 @@ export default {
) {
this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
+ this.modelExperimentsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.modelExperimentsAccessLevel,
+ );
this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel);
this.snippetsAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.snippetsAccessLevel,
);
- this.metricsDashboardAccessLevel = Math.min(
- featureAccessLevel.PROJECT_MEMBERS,
- this.metricsDashboardAccessLevel,
- );
this.analyticsAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.analyticsAccessLevel,
@@ -458,14 +470,14 @@ export default {
this.buildsAccessLevel = featureAccessLevel.EVERYONE;
if (this.wikiAccessLevel > featureAccessLevel.NOT_ENABLED)
this.wikiAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.modelExperimentsAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.modelExperimentsAccessLevel = featureAccessLevel.EVERYONE;
if (this.snippetsAccessLevel > featureAccessLevel.NOT_ENABLED)
this.snippetsAccessLevel = featureAccessLevel.EVERYONE;
if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.pagesAccessLevel = featureAccessLevel.EVERYONE;
if (this.analyticsAccessLevel > featureAccessLevel.NOT_ENABLED)
this.analyticsAccessLevel = featureAccessLevel.EVERYONE;
- if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
- this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE;
if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.requirementsAccessLevel = featureAccessLevel.EVERYONE;
if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
@@ -503,22 +515,9 @@ export default {
else if (oldValue === featureAccessLevel.NOT_ENABLED)
toggleHiddenClassBySelector('.merge-requests-feature', false);
},
-
- monitorAccessLevel(value, oldValue) {
- this.updateSubFeatureAccessLevel(value, oldValue);
- },
},
methods: {
- updateSubFeatureAccessLevel(value, oldValue) {
- if (value < oldValue) {
- // sub-features cannot have more permissive access level
- this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value);
- } else if (oldValue === 0) {
- this.metricsDashboardAccessLevel = value;
- }
- },
-
highlightChanges() {
this.highlightChangesClass = true;
this.$nextTick(() => {
@@ -552,7 +551,7 @@ export default {
<template>
<div>
<div
- class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5"
+ class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-5"
>
<project-setting-row
ref="project-visibility-settings"
@@ -647,7 +646,7 @@ export default {
</div>
<div
:class="{ 'highlight-changes': highlightChangesClass }"
- class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10"
+ class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-5 gl-bg-gray-10"
>
<project-setting-row
ref="issues-settings"
@@ -693,7 +692,7 @@ export default {
name="project[project_feature_attributes][repository_access_level]"
/>
</project-setting-row>
- <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
+ <div class="project-feature-setting-group gl-pl-5 gl-md-pl-7">
<project-setting-row
ref="merge-request-settings"
:label="$options.i18n.mergeRequestsLabel"
@@ -875,7 +874,7 @@ export default {
/>
<div
v-if="packageRegistryApiForEveryoneEnabledShown"
- class="project-feature-setting-group gl-pl-7 gl-sm-pl-5 gl-my-3"
+ class="project-feature-setting-group gl-pl-5 gl-md-pl-7 gl-my-3"
>
<project-setting-row
:label="$options.i18n.packageRegistryForEveryoneLabel"
@@ -899,6 +898,19 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ ref="model-experiments-settings"
+ :label="$options.i18n.modelExperimentsLabel"
+ :help-text="$options.i18n.modelExperimentsHelpText"
+ :help-path="$options.modelExperimentsHelpPath"
+ >
+ <project-feature-setting
+ v-model="modelExperimentsAccessLevel"
+ :label="$options.i18n.modelExperimentsLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][model_experiments_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath"
@@ -930,23 +942,6 @@ export default {
name="project[project_feature_attributes][monitor_access_level]"
/>
</project-setting-row>
- <div
- v-if="!glFeatures.removeMonitorMetrics"
- class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"
- >
- <project-setting-row
- ref="metrics-visibility-settings"
- :label="__('Metrics Dashboard')"
- :help-text="s__('ProjectSettings|Visualize the project\'s performance metrics.')"
- >
- <project-feature-setting
- v-model="metricsDashboardAccessLevel"
- :show-toggle="false"
- :options="monitorOperationsFeatureAccessLevelOptions"
- name="project[project_feature_attributes][metrics_dashboard_access_level]"
- />
- </project-setting-row>
- </div>
<project-setting-row
ref="environments-settings"
:label="$options.i18n.environmentsLabel"
@@ -1000,6 +995,11 @@ export default {
/>
</project-setting-row>
</div>
+ <ci-catalog-settings
+ v-if="canAddCatalogResource"
+ class="gl-mb-5"
+ :full-path="confirmationPhrase"
+ />
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index 4c687859344..522cc7cfc1a 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -4,6 +4,7 @@ import {
VISIBILITY_LEVEL_INTERNAL_INTEGER,
VISIBILITY_LEVEL_PUBLIC_INTEGER,
} from '~/visibility_level/constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const visibilityLevelDescriptions = {
[VISIBILITY_LEVEL_PRIVATE_INTEGER]: __(
@@ -43,3 +44,7 @@ export const featureAccessLevelEveryone = [
export const CVE_ID_REQUEST_BUTTON_I18N = {
cve_request_toggle_label: s__('CVE|Enable CVE ID requests in the issue sidebar'),
};
+
+export const modelExperimentsHelpPath = helpPagePath(
+ 'user/project/ml/experiment_tracking/index.md',
+);
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql
new file mode 100644
index 00000000000..c3b73ebf248
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql
@@ -0,0 +1,5 @@
+mutation catalogResourcesCreate($input: CatalogResourcesCreateInput!) {
+ catalogResourcesCreate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql b/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql
new file mode 100644
index 00000000000..0de06028386
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql
@@ -0,0 +1,6 @@
+query getCiCatalogSettings($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ isCatalogResource
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
index de8b1cc400e..4b4589dc46c 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/index.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
@@ -1,8 +1,17 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
import { parseBoolean } from '~/lib/utils/common_utils';
import settingsPanel from './components/settings_panel.vue';
+Vue.use(VueApollo);
+
export default function initProjectPermissionsSettings() {
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
const mountPoint = document.querySelector('.js-project-permissions-form');
const componentPropsEl = document.querySelector('.js-project-permissions-form-data');
const componentProps = JSON.parse(componentPropsEl.innerHTML);
@@ -19,6 +28,8 @@ export default function initProjectPermissionsSettings() {
return new Vue({
el: mountPoint,
+ name: 'ProjectPermissionsRoot',
+ apolloProvider,
provide: {
additionalInformation,
confirmDangerMessage,
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
index 43ff617dabe..ce36ff6a230 100644
--- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
@@ -1,7 +1,15 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import WebIdeButton from '~/vue_shared/components/web_ide_link.vue';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export default ({ el, router }) => {
if (!el) return;
@@ -15,6 +23,10 @@ export default ({ el, router }) => {
new Vue({
el,
router,
+ apolloProvider,
+ provide: {
+ projectPath,
+ },
render(h) {
return h(WebIdeButton, {
props: {
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 33d4090011f..e17f5255c54 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert';
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
@@ -42,8 +40,6 @@ initVueNotificationsDropdown();
new ShortcutsNavigation(); // eslint-disable-line no-new
initUploadFileTrigger();
-initInviteMembersModal();
-initInviteMembersTrigger();
initClustersDeprecationAlert();
initTerraformNotification();
diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js
index 6eef2352e2c..11c257611f0 100644
--- a/app/assets/javascripts/pages/projects/work_items/index.js
+++ b/app/assets/javascripts/pages/projects/work_items/index.js
@@ -1,5 +1,3 @@
import { initWorkItemsRoot } from '~/work_items/index';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
initWorkItemsRoot();
-initInviteMembersModal();
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 3b38d715ea5..4f68c7984e8 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -107,7 +107,7 @@ export default {
MarkdownEditor,
},
mixins: [trackingMixin],
- inject: ['formatOptions', 'pageInfo'],
+ inject: ['formatOptions', 'pageInfo', 'drawioUrl'],
data() {
return {
editingMode: 'source',
@@ -183,6 +183,9 @@ export default {
disableSubmitButton() {
return this.noContent || !this.title;
},
+ drawioEnabled() {
+ return typeof this.drawioUrl === 'string' && this.drawioUrl.length > 0;
+ },
},
mounted() {
if (!this.commitMessage) this.updateCommitMessage();
@@ -356,7 +359,7 @@ export default {
:autofocus="pageInfo.persisted"
:enable-autocomplete="true"
:autocomplete-data-sources="autocompleteDataSources"
- :drawio-enabled="true"
+ :drawio-enabled="drawioEnabled"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
@keydown.ctrl.enter="submitFormShortcut"
diff --git a/app/assets/javascripts/pages/shared/wikis/edit.js b/app/assets/javascripts/pages/shared/wikis/edit.js
index 02878633916..0044575de62 100644
--- a/app/assets/javascripts/pages/shared/wikis/edit.js
+++ b/app/assets/javascripts/pages/shared/wikis/edit.js
@@ -70,6 +70,7 @@ const createWikiFormApp = () => {
provide: {
formatOptions: JSON.parse(formatOptions),
pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ drawioUrl: gon.diagramsnet_url,
},
render(createElement) {
return createElement(wikiForm);
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index 30c351359e4..af55a5dc01a 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -2,11 +2,16 @@ import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
import { initReportAbuse } from '~/users/profile';
+import { initProfileTabs } from '~/profile';
import UserTabs from './user_tabs';
function initUserProfile(action) {
- // eslint-disable-next-line no-new
- new UserTabs({ parentEl: '.user-profile', action });
+ if (gon.features?.profileTabsVue) {
+ initProfileTabs();
+ } else {
+ // eslint-disable-next-line no-new
+ new UserTabs({ parentEl: '.user-profile', action });
+ }
// hide project limit message
$('.hide-project-limit-message').on('click', (e) => {
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
index c213753257d..47424ec1dd3 100644
--- a/app/assets/javascripts/pages/users/show/index.js
+++ b/app/assets/javascripts/pages/users/show/index.js
@@ -1,7 +1,3 @@
-import { initProfileTabs, initUserAchievements } from '~/profile';
-
-if (gon.features?.profileTabsVue) {
- initProfileTabs();
-}
+import { initUserAchievements } from '~/profile';
initUserAchievements();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 3130fe42c3c..e7f2662ae09 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -11,7 +11,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
'.js-approaching-seat-count-threshold',
- '.js-storage-enforcement-banner',
+ '.js-storage-pre-enforcement-alert',
'.js-user-over-limit-free-plan-alert',
'.js-minute-limit-banner',
'.js-submit-license-usage-data-banner',
@@ -24,6 +24,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-geo-migrate-hashed-storage-callout',
'.js-unlimited-members-during-trial-alert',
'.js-branch-rules-info-callout',
+ '.js-new-navigation-callout',
+ '.js-code-suggestions-third-party-callout',
+ '.js-namespace-over-storage-users-combined-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue
new file mode 100644
index 00000000000..8fe6707028a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue
@@ -0,0 +1,629 @@
+<script>
+import {
+ GlAlert,
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { __, s__, sprintf, formatNumber } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import {
+ LOAD_FAILURE,
+ POST_FAILURE,
+ DELETE_FAILURE,
+ DEFAULT,
+ BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
+} from '../constants';
+import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
+import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
+import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
+import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import TimeAgo from './pipelines_list/time_ago.vue';
+import { getQueryHeaders } from './graph/utils';
+
+const DELETE_MODAL_ID = 'pipeline-delete-modal';
+const POLL_INTERVAL = 10000;
+
+export default {
+ name: 'PipelineDetailsHeader',
+ BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
+ pipelineCancel: 'pipelineCancel',
+ pipelineRetry: 'pipelineRetry',
+ finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
+ components: {
+ CiBadgeLink,
+ ClipboardButton,
+ GlAlert,
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlModal,
+ GlSprintf,
+ TimeAgo,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ i18n: {
+ scheduleBadgeText: s__('Pipelines|Scheduled'),
+ scheduleBadgeTooltip: __('This pipeline was triggered by a schedule'),
+ childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'),
+ childBadgeTooltip: __('This is a child pipeline within the parent pipeline'),
+ latestBadgeText: s__('Pipelines|latest'),
+ latestBadgeTooltip: __('Latest pipeline for the most recent commit on this branch'),
+ mergeTrainBadgeText: s__('Pipelines|merge train'),
+ mergeTrainBadgeTooltip: s__(
+ 'Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.',
+ ),
+ invalidBadgeText: s__('Pipelines|yaml invalid'),
+ failedBadgeText: s__('Pipelines|error'),
+ autoDevopsBadgeText: s__('Pipelines|Auto DevOps'),
+ autoDevopsBadgeTooltip: __(
+ 'This pipeline makes use of a predefined CI/CD configuration enabled by Auto DevOps.',
+ ),
+ detachedBadgeText: s__('Pipelines|merge request'),
+ detachedBadgeTooltip: s__(
+ "Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.",
+ ),
+ stuckBadgeText: s__('Pipelines|stuck'),
+ stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'),
+ computeCreditsTooltip: s__('Pipelines|Total amount of compute credits used for the pipeline'),
+ totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'),
+ retryPipelineText: __('Retry'),
+ cancelPipelineText: __('Cancel pipeline'),
+ deletePipelineText: __('Delete'),
+ clipboardTooltip: __('Copy commit SHA'),
+ },
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
+ [POST_FAILURE]: __('An error occurred while making the request.'),
+ [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
+ modal: {
+ id: DELETE_MODAL_ID,
+ title: __('Delete pipeline'),
+ deleteConfirmationText: __(
+ 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
+ ),
+ actionPrimary: {
+ text: __('Delete pipeline'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
+ inject: {
+ graphqlResourceEtag: {
+ default: '',
+ },
+ paths: {
+ default: {},
+ },
+ pipelineIid: {
+ default: '',
+ },
+ },
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ totalJobs: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ computeCredits: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ yamlErrors: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ failureReason: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ refText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ badges: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ apollo: {
+ pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.paths.fullProject,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return data.project.pipeline;
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ pollInterval: POLL_INTERVAL,
+ watchLoading(isLoading) {
+ if (!isLoading) {
+ // To ensure apollo has updated the cache,
+ // we only remove the loading state in sync with GraphQL
+ this.isCanceling = false;
+ this.isRetrying = false;
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ pipeline: null,
+ failureMessages: [],
+ failureType: null,
+ isCanceling: false,
+ isRetrying: false,
+ isDeleting: false,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.pipeline.loading;
+ },
+ hasError() {
+ return this.failureType;
+ },
+ hasPipelineData() {
+ return Boolean(this.pipeline);
+ },
+ isLoadingInitialQuery() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ detailedStatus() {
+ return this.pipeline?.detailedStatus || {};
+ },
+ status() {
+ return this.pipeline?.status;
+ },
+ isFinished() {
+ return this.$options.finishedStatuses.includes(this.status);
+ },
+ shouldRenderContent() {
+ return !this.isLoadingInitialQuery && this.hasPipelineData;
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case POST_FAILURE:
+ return {
+ text: this.$options.errorTexts[POST_FAILURE],
+ variant: 'danger',
+ };
+ case DELETE_FAILURE:
+ return {
+ text: this.$options.errorTexts[DELETE_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
+ user() {
+ return this.pipeline?.user;
+ },
+ userId() {
+ return getIdFromGraphQLId(this.user?.id);
+ },
+ shortId() {
+ return this.pipeline?.commit?.shortId || '';
+ },
+ commitPath() {
+ return this.pipeline?.commit?.webPath || '';
+ },
+ commitTitle() {
+ return this.pipeline?.commit?.title || '';
+ },
+ totalJobsText() {
+ return sprintf(__('%{jobs} Jobs'), {
+ jobs: this.totalJobs,
+ });
+ },
+ triggeredText() {
+ return sprintf(__('triggered pipeline for commit %{linkStart}%{shortId}%{linkEnd}'), {
+ shortId: this.shortId,
+ });
+ },
+ inProgress() {
+ return this.status === 'RUNNING';
+ },
+ duration() {
+ return this.pipeline?.duration || 0;
+ },
+ showDuration() {
+ return this.duration && this.isFinished;
+ },
+ durationFormatted() {
+ return timeIntervalInWords(this.duration);
+ },
+ queuedDuration() {
+ return this.pipeline?.queuedDuration || 0;
+ },
+ inProgressText() {
+ return sprintf(__('In progress, queued for %{queuedDuration} seconds'), {
+ queuedDuration: formatNumber(this.queuedDuration),
+ });
+ },
+ durationText() {
+ return sprintf(__('%{duration}, queued for %{queuedDuration} seconds'), {
+ duration: this.durationFormatted,
+ queuedDuration: formatNumber(this.queuedDuration),
+ });
+ },
+ canRetryPipeline() {
+ const { retryable, userPermissions } = this.pipeline;
+
+ return retryable && userPermissions.updatePipeline;
+ },
+ canCancelPipeline() {
+ const { cancelable, userPermissions } = this.pipeline;
+
+ return cancelable && userPermissions.updatePipeline;
+ },
+ showComputeCredits() {
+ return this.isFinished && this.computeCredits !== '0.0';
+ },
+ },
+ methods: {
+ reportFailure(errorType, errorMessages = []) {
+ this.failureType = errorType;
+ this.failureMessages = errorMessages;
+ },
+ async postPipelineAction(name, mutation) {
+ try {
+ const {
+ data: {
+ [name]: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation,
+ variables: { id: this.pipeline.id },
+ });
+
+ if (errors.length > 0) {
+ this.isRetrying = false;
+
+ this.reportFailure(POST_FAILURE, errors);
+ } else {
+ await this.$apollo.queries.pipeline.refetch();
+ if (!this.isFinished) {
+ this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
+ }
+ }
+ } catch {
+ this.isRetrying = false;
+
+ this.reportFailure(POST_FAILURE);
+ }
+ },
+ cancelPipeline() {
+ this.isCanceling = true;
+ this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation);
+ },
+ retryPipeline() {
+ this.isRetrying = true;
+ this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation);
+ },
+ async deletePipeline() {
+ this.isDeleting = true;
+ this.$apollo.queries.pipeline.stopPolling();
+
+ try {
+ const {
+ data: {
+ pipelineDestroy: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: deletePipelineMutation,
+ variables: {
+ id: this.pipeline.id,
+ },
+ });
+
+ if (errors.length > 0) {
+ this.reportFailure(DELETE_FAILURE, errors);
+ this.isDeleting = false;
+ } else {
+ redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated
+ }
+ } catch {
+ this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
+ this.reportFailure(DELETE_FAILURE);
+ this.isDeleting = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-my-4">
+ <gl-alert
+ v-if="hasError"
+ class="gl-mb-4"
+ :title="failure.text"
+ :variant="failure.variant"
+ :dismissible="false"
+ >
+ <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`">
+ {{ failureMessage }}
+ </div>
+ </gl-alert>
+ <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" />
+ <div
+ v-else
+ class="gl-display-flex gl-justify-content-space-between"
+ data-qa-selector="pipeline_details_header"
+ >
+ <div>
+ <h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3>
+ <h3 v-else class="gl-mt-0 gl-mb-2" data-testid="pipeline-commit-title">
+ {{ commitTitle }}
+ </h3>
+ <div>
+ <ci-badge-link :status="detailedStatus" />
+ <div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6">
+ <gl-link
+ v-if="user"
+ :href="user.webUrl"
+ class="gl-display-inline-block gl-text-gray-900 gl-font-weight-bold js-user-link"
+ :data-user-id="userId"
+ :data-username="user.username"
+ data-testid="pipeline-user-link"
+ >
+ {{ user.name }}
+ </gl-link>
+ <gl-sprintf :message="triggeredText">
+ <template #link="{ content }">
+ <gl-link
+ :href="commitPath"
+ class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2"
+ data-testid="commit-link"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <clipboard-button
+ :text="shortId"
+ category="tertiary"
+ :title="$options.i18n.clipboardTooltip"
+ size="small"
+ />
+ <time-ago
+ v-if="isFinished"
+ :pipeline="pipeline"
+ class="gl-display-inline gl-mb-0"
+ :display-calendar-icon="false"
+ font-size="gl-font-md"
+ />
+ </div>
+ </div>
+ <div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div>
+ <div>
+ <gl-badge
+ v-if="badges.schedule"
+ v-gl-tooltip
+ :title="$options.i18n.scheduleBadgeTooltip"
+ variant="info"
+ size="sm"
+ >
+ {{ $options.i18n.scheduleBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.child"
+ v-gl-tooltip
+ :title="$options.i18n.childBadgeTooltip"
+ variant="info"
+ size="sm"
+ >
+ <gl-sprintf :message="$options.i18n.childBadgeText">
+ <template #link="{ content }">
+ <gl-link :href="paths.triggeredByPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-badge>
+ <gl-badge
+ v-if="badges.latest"
+ v-gl-tooltip
+ :title="$options.i18n.latestBadgeTooltip"
+ variant="success"
+ size="sm"
+ >
+ {{ $options.i18n.latestBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.mergeTrainPipeline"
+ v-gl-tooltip
+ :title="$options.i18n.mergeTrainBadgeTooltip"
+ variant="info"
+ size="sm"
+ >
+ {{ $options.i18n.mergeTrainBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.invalid"
+ v-gl-tooltip
+ :title="yamlErrors"
+ variant="danger"
+ size="sm"
+ >
+ {{ $options.i18n.invalidBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.failed"
+ v-gl-tooltip
+ :title="failureReason"
+ variant="danger"
+ size="sm"
+ >
+ {{ $options.i18n.failedBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.autoDevops"
+ v-gl-tooltip
+ :title="$options.i18n.autoDevopsBadgeTooltip"
+ variant="info"
+ size="sm"
+ >
+ {{ $options.i18n.autoDevopsBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.detached"
+ v-gl-tooltip
+ :title="$options.i18n.detachedBadgeTooltip"
+ variant="info"
+ size="sm"
+ data-qa-selector="merge_request_badge_tag"
+ >
+ {{ $options.i18n.detachedBadgeText }}
+ </gl-badge>
+ <gl-badge
+ v-if="badges.stuck"
+ v-gl-tooltip
+ :title="$options.i18n.stuckBadgeTooltip"
+ variant="warning"
+ size="sm"
+ >
+ {{ $options.i18n.stuckBadgeText }}
+ </gl-badge>
+ <span
+ v-gl-tooltip
+ :title="$options.i18n.totalJobsTooltip"
+ class="gl-ml-2"
+ data-testid="total-jobs"
+ >
+ <gl-icon name="pipeline" />
+ {{ totalJobsText }}
+ </span>
+ <span
+ v-if="showComputeCredits"
+ v-gl-tooltip
+ :title="$options.i18n.computeCreditsTooltip"
+ class="gl-ml-2"
+ data-testid="compute-credits"
+ >
+ <gl-icon name="quota" />
+ {{ computeCredits }}
+ </span>
+ <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text">
+ <gl-icon name="timer" />
+ {{ inProgressText }}
+ </span>
+ <span v-if="showDuration" class="gl-ml-2" data-testid="pipeline-duration-text">
+ <gl-icon name="timer" />
+ {{ durationText }}
+ </span>
+ </div>
+ </div>
+ <div>
+ <gl-button
+ v-if="canRetryPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_RETRY"
+ :title="$options.BUTTON_TOOLTIP_RETRY"
+ :loading="isRetrying"
+ :disabled="isRetrying"
+ variant="confirm"
+ data-testid="retry-pipeline"
+ class="js-retry-button"
+ @click="retryPipeline()"
+ >
+ {{ $options.i18n.retryPipelineText }}
+ </gl-button>
+
+ <gl-button
+ v-if="canCancelPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
+ :title="$options.BUTTON_TOOLTIP_CANCEL"
+ :loading="isCanceling"
+ :disabled="isCanceling"
+ class="gl-ml-3"
+ variant="danger"
+ data-testid="cancel-pipeline"
+ @click="cancelPipeline()"
+ >
+ {{ $options.i18n.cancelPipelineText }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.userPermissions.destroyPipeline"
+ v-gl-modal="$options.modal.id"
+ :loading="isDeleting"
+ :disabled="isDeleting"
+ class="gl-ml-3"
+ variant="danger"
+ category="secondary"
+ data-testid="delete-pipeline"
+ >
+ {{ $options.i18n.deletePipelineText }}
+ </gl-button>
+ </div>
+ </div>
+ <gl-modal
+ :modal-id="$options.modal.id"
+ :title="$options.modal.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="deletePipeline()"
+ >
+ <p>
+ {{ $options.modal.deleteConfirmationText }}
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue
new file mode 100644
index 00000000000..91630d4cfd4
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue
@@ -0,0 +1,149 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
+import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/pipelines/constants';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
+import PipelineMiniGraph from './pipeline_mini_graph.vue';
+
+export default {
+ i18n: {
+ linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'),
+ stagesFetchError: __('There was a problem fetching the pipeline stages.'),
+ },
+ components: {
+ GlLoadingIcon,
+ PipelineMiniGraph,
+ },
+ props: {
+ pipelineEtag: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pollInterval: {
+ type: Number,
+ required: false,
+ default: PIPELINE_MINI_GRAPH_POLL_INTERVAL,
+ },
+ },
+ data() {
+ return {
+ linkedPipelines: null,
+ pipelineStages: [],
+ };
+ },
+ apollo: {
+ linkedPipelines: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getLinkedPipelinesQuery,
+ pollInterval() {
+ return this.pollInterval;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline || this.linkedpipelines;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.linkedPipelinesFetchError });
+ },
+ },
+ pipelineStages: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
+ query: getPipelineStagesQuery,
+ pollInterval() {
+ return this.pollInterval;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update({ project }) {
+ return project?.pipeline?.stages?.nodes || this.pipelineStages;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.stagesFetchError });
+ },
+ },
+ },
+ computed: {
+ downstreamPipelines() {
+ return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes);
+ },
+ formattedStages() {
+ return this.pipelineStages.map((stage) => {
+ const { name, detailedStatus } = stage;
+ return {
+ // TODO: Once we fetch stage by ID with GraphQL,
+ // this method will change.
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853
+ id: stage.id,
+ dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
+ name,
+ path: `${this.pipelinePath}#${name}`,
+ status: {
+ details_path: `${this.pipelinePath}#${name}`,
+ has_details: detailedStatus?.hasDetails || false,
+ ...detailedStatus,
+ },
+ title: `${name}: ${detailedStatus?.text || ''}`,
+ };
+ });
+ },
+ pipelinePath() {
+ return this.linkedPipelines?.path || '';
+ },
+ upstreamPipeline() {
+ return this.linkedPipelines?.upstream;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines);
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" />
+ <pipeline-mini-graph
+ v-else
+ data-testid="graphql-pipeline-mini-graph"
+ :downstream-pipelines="downstreamPipelines"
+ :is-merge-train="isMergeTrain"
+ :pipeline-path="pipelinePath"
+ :stages="formattedStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
new file mode 100644
index 00000000000..fce0b5f525e
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlPopover,
+ GlSprintf,
+} from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __, s__ } from '~/locale';
+import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import WidgetFailedJobRow from './widget_failed_job_row.vue';
+import { sortJobsByStatus } from './utils';
+
+const JOB_ID_HEADER = __('Job ID');
+const JOB_NAME_HEADER = __('Job name');
+const STAGE_HEADER = __('Stage');
+
+export default {
+ components: {
+ GlButton,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlPopover,
+ GlSprintf,
+ WidgetFailedJobRow,
+ },
+ inject: ['fullPath'],
+ props: {
+ pipelineIid: {
+ required: true,
+ type: Number,
+ },
+ pipelinePath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ failedJobs: [],
+ isExpanded: false,
+ };
+ },
+ apollo: {
+ failedJobs: {
+ query: getPipelineFailedJobs,
+ skip() {
+ return !this.isExpanded;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ pipelineIid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ const jobs = data?.project?.pipeline?.jobs?.nodes || [];
+ return sortJobsByStatus(jobs);
+ },
+ error(e) {
+ createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' });
+ },
+ },
+ },
+ computed: {
+ bodyClasses() {
+ return this.isExpanded ? '' : 'gl-display-none';
+ },
+ failedJobsCount() {
+ return this.failedJobs.length;
+ },
+ iconName() {
+ return this.isExpanded ? 'chevron-down' : 'chevron-right';
+ },
+ isLoading() {
+ return this.$apollo.queries.failedJobs.loading;
+ },
+ },
+ methods: {
+ toggleWidget() {
+ this.isExpanded = !this.isExpanded;
+ },
+ },
+ columns: [
+ { text: JOB_NAME_HEADER, class: 'col-6' },
+ { text: STAGE_HEADER, class: 'col-2' },
+ { text: JOB_ID_HEADER, class: 'col-2' },
+ ],
+ i18n: {
+ additionalInfoPopover: s__(
+ 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.',
+ ),
+ additionalInfoTitle: __('Limitation on this view'),
+ fetchError: __('There was a problem fetching failed jobs'),
+ showFailedJobs: __('Show failed jobs'),
+ },
+};
+</script>
+<template>
+ <div class="gl-border-none!">
+ <gl-button variant="link" @click="toggleWidget">
+ <gl-icon :name="iconName" />
+ {{ $options.i18n.showFailedJobs }}
+ <gl-icon id="target" name="information-o" />
+ <gl-popover target="target" placement="top">
+ <template #title> {{ $options.i18n.additionalInfoTitle }} </template>
+ <slot>
+ <gl-sprintf :message="$options.i18n.additionalInfoPopover">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="pipelinePath"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </slot>
+ </gl-popover>
+ </gl-button>
+ <gl-loading-icon v-if="isLoading" />
+ <gl-collapse
+ v-else
+ v-model="isExpanded"
+ class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3"
+ >
+ <div class="container-fluid gl-grid-tpl-rows-auto">
+ <div class="row gl-mb-6 gl-text-gray-900">
+ <div
+ v-for="col in $options.columns"
+ :key="col.text"
+ class="gl-font-weight-bold gl-text-left"
+ :class="col.class"
+ data-testid="header"
+ >
+ {{ col.text }}
+ </div>
+ </div>
+ </div>
+ <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" />
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js
new file mode 100644
index 00000000000..3f395fff7e0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js
@@ -0,0 +1,15 @@
+export const isFailedJob = (job = {}) => {
+ return job?.detailedStatus?.group === 'failed' || false;
+};
+
+export const sortJobsByStatus = (jobs = []) => {
+ const newJobs = [...jobs];
+
+ return newJobs.sort((a) => {
+ if (isFailedJob(a)) {
+ return -1;
+ }
+
+ return 1;
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue
new file mode 100644
index 00000000000..e40e30f2b8d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlCollapse, GlIcon, GlLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+ components: {
+ CiIcon,
+ GlCollapse,
+ GlIcon,
+ GlLink,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isJobLogVisible: false,
+ isHovered: false,
+ };
+ },
+ computed: {
+ activeClass() {
+ return this.isHovered ? 'gl-bg-gray-50' : '';
+ },
+ isVisibleId() {
+ return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`;
+ },
+ jobChevronName() {
+ return this.isJobLogVisible ? 'chevron-down' : 'chevron-right';
+ },
+ jobTrace() {
+ return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText;
+ },
+ parsedJobId() {
+ return getIdFromGraphQLId(this.job.id);
+ },
+ tooltipText() {
+ return sprintf(this.$options.i18n.jobActionTooltipText, { jobName: this.job.name });
+ },
+ },
+ methods: {
+ setActiveRow() {
+ this.isHovered = true;
+ },
+ resetActiveRow() {
+ this.isHovered = false;
+ },
+ toggleJobLog(e) {
+ // Do not toggle the log visibility when clicking on a link
+ if (e.target.tagName === 'A') {
+ return;
+ }
+
+ this.isJobLogVisible = !this.isJobLogVisible;
+ },
+ },
+ i18n: {
+ jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'),
+ noTraceText: s__('Job|No job log'),
+ },
+};
+</script>
+<template>
+ <div class="container-fluid gl-grid-tpl-rows-auto">
+ <div
+ class="row gl-py-4 gl-cursor-pointer gl-display-flex gl-align-items-center"
+ :class="activeClass"
+ :aria-pressed="isJobLogVisible"
+ role="button"
+ tabindex="0"
+ data-testid="widget-row"
+ @click="toggleJobLog"
+ @keyup.enter="toggleJobLog"
+ @keyup.space="toggleJobLog"
+ @mouseover="setActiveRow"
+ @mouseout="resetActiveRow"
+ >
+ <div class="col-6 gl-text-gray-900 gl-font-weight-bold gl-text-left">
+ <gl-icon :name="jobChevronName" class="gl-fill-blue-500" />
+ <ci-icon :status="job.detailedStatus" />
+ {{ job.name }}
+ </div>
+ <div class="col-2 gl-text-left">{{ job.stage.name }}</div>
+ <div class="col-2 gl-text-left">
+ <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link>
+ </div>
+ </div>
+ <div class="row">
+ <gl-collapse :visible="isJobLogVisible" class="gl-w-full">
+ <pre
+ v-safe-html="jobTrace"
+ class="gl-bg-gray-900 gl-text-white"
+ :data-testid="isVisibleId"
+ ></pre>
+ </gl-collapse>
+ </div>
+ </div>
+</template>
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 7ad12d397e5..ff1a01d5037 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -32,6 +32,11 @@ export default {
type: String,
required: true,
},
+ refClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
mergeRequestRef() {
@@ -134,7 +139,12 @@ export default {
:title="pipelineName"
class="gl-flex-grow-1 gl-text-truncate gl-text-gray-900"
>
- {{ pipelineName }}
+ <gl-link
+ :href="pipeline.path"
+ class="gl-text-blue-600!"
+ data-testid="pipeline-url-link"
+ >{{ pipelineName }}</gl-link
+ >
</tooltip-on-truncate>
</span>
</div>
@@ -144,7 +154,7 @@ export default {
<tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
:href="commitUrl"
- class="commit-row-message gl-font-weight-bold gl-text-gray-900"
+ class="commit-row-message gl-text-blue-600!"
data-testid="commit-title"
@click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
@@ -158,53 +168,61 @@ export default {
<div class="gl-mb-2">
<gl-link
:href="pipeline.path"
- class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
+ class="gl-mr-1 gl-text-blue-500!"
data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
@click="trackClick('click_pipeline_id')"
>#{{ pipeline[pipelineKey] }}</gl-link
>
<!--Commit row-->
- <div class="icon-container gl-display-inline-block gl-mr-1">
+ <div class="gl-display-inline-flex gl-rounded-base gl-px-2 gl-bg-gray-50 gl-text-gray-700">
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
+ <gl-icon
+ v-gl-tooltip
+ :name="commitIcon"
+ :title="commitIconTooltipTitle"
+ :size="12"
+ data-testid="commit-icon-type"
+ />
+ <gl-link
+ v-if="mergeRequestRef"
+ :href="mergeRequestRef.path"
+ class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!"
+ :class="refClass"
+ data-testid="merge-request-ref"
+ @click="trackClick('click_mr_ref')"
+ >{{ mergeRequestRef.iid }}</gl-link
+ >
+ <gl-link
+ v-else
+ :href="refUrl"
+ class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!"
+ :class="refClass"
+ data-testid="commit-ref-name"
+ @click="trackClick('click_commit_name')"
+ >{{ commitRef.name }}</gl-link
+ >
+ </tooltip-on-truncate>
+ </div>
+ <div
+ class="gl-display-inline-block gl-rounded-base gl-font-sm gl-px-2 gl-bg-gray-50 gl-text-black-normal"
+ >
<gl-icon
v-gl-tooltip
- :name="commitIcon"
- :title="commitIconTooltipTitle"
- data-testid="commit-icon-type"
+ name="commit"
+ class="commit-icon gl-mr-1"
+ :title="__('Commit')"
+ :size="12"
+ data-testid="commit-icon"
/>
- </div>
- <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
- <gl-link
- v-if="mergeRequestRef"
- :href="mergeRequestRef.path"
- class="ref-name gl-mr-3"
- data-testid="merge-request-ref"
- @click="trackClick('click_mr_ref')"
- >{{ mergeRequestRef.iid }}</gl-link
- >
<gl-link
- v-else
- :href="refUrl"
- class="ref-name gl-mr-3"
- data-testid="commit-ref-name"
- @click="trackClick('click_commit_name')"
- >{{ commitRef.name }}</gl-link
+ :href="commitUrl"
+ class="gl-font-sm gl-font-monospace gl-mr-0 gl-text-gray-700!"
+ data-testid="commit-short-sha"
+ @click="trackClick('click_commit_sha')"
+ >{{ commitShortSha }}</gl-link
>
- </tooltip-on-truncate>
- <gl-icon
- v-gl-tooltip
- name="commit"
- class="commit-icon gl-mr-1"
- :title="__('Commit')"
- data-testid="commit-icon"
- />
- <gl-link
- :href="commitUrl"
- class="commit-sha mr-0"
- data-testid="commit-short-sha"
- @click="trackClick('click_commit_sha')"
- >{{ commitShortSha }}</gl-link
- >
+ </div>
<user-avatar-link
v-if="commitAuthor"
:link-href="commitAuthor.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index bcbf655a737..7d41700c492 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,12 +1,15 @@
<script>
import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { isEqual } from 'lodash';
+import * as Sentry from '@sentry/browser';
import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import {
ANY_TRIGGER_AUTHOR,
RAW_TEXT_WARNING,
@@ -113,6 +116,11 @@ export default {
required: false,
default: null,
},
+ defaultVisibilityPipelineIdType: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -123,7 +131,7 @@ export default {
page: getParameterByName('page') || '1',
requestData: {},
isResetCacheButtonLoading: false,
- selectedPipelineKeyOption: this.$options.PipelineKeyOptions[0],
+ visibilityPipelineIdType: this.defaultVisibilityPipelineIdType,
};
},
stateMap: {
@@ -232,6 +240,12 @@ export default {
validatedParams() {
return validateParams(this.params);
},
+ selectedPipelineKeyOption() {
+ return (
+ this.$options.PipelineKeyOptions.find((e) => this.visibilityPipelineIdType === e.value) ||
+ this.$options.PipelineKeyOptions[0]
+ );
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -317,8 +331,26 @@ export default {
this.updateContent({ ...this.requestData, page: '1' });
},
- changeVisibilityPipelineID(val) {
- this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value);
+ changeVisibilityPipelineIDType(idType) {
+ this.visibilityPipelineIdType = idType;
+ this.saveVisibilityPipelineIDType(idType);
+ },
+ saveVisibilityPipelineIDType(idType) {
+ if (!isLoggedIn()) return;
+
+ this.$apollo
+ .mutate({
+ mutation: setSortPreferenceMutation,
+ variables: { input: { visibilityPipelineIdType: idType.toUpperCase() } },
+ })
+ .then(({ data }) => {
+ if (data.userPreferencesUpdate.errors.length) {
+ throw new Error(data.userPreferencesUpdate.errors);
+ }
+ })
+ .catch((error) => {
+ Sentry.captureException(error);
+ });
},
},
};
@@ -359,10 +391,11 @@ export default {
@filterPipelines="filterPipelines"
/>
<gl-collapsible-listbox
+ v-model="visibilityPipelineIdType"
data-testid="pipeline-key-collapsible-box"
:toggle-text="selectedPipelineKeyOption.text"
:items="$options.PipelineKeyOptions"
- @select="changeVisibilityPipelineID"
+ @select="changeVisibilityPipelineIDType"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index b2da0df17c0..d884935d95b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -2,8 +2,10 @@
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
import PipelineOperations from './pipeline_operations.vue';
@@ -12,7 +14,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-const DEFAULT_TD_CLASS = 'gl-p-5!';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
@@ -20,6 +21,7 @@ const DEFAULT_TH_CLASSES =
export default {
components: {
GlTableLite,
+ PipelineFailedJobsWidget,
PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
@@ -27,51 +29,15 @@ export default {
PipelineTriggerer,
PipelineUrl,
},
- tableFields: [
- {
- key: 'status',
- label: s__('Pipeline|Status'),
- thClass: DEFAULT_TH_CLASSES,
- columnClass: 'gl-w-15p',
- tdClass: DEFAULT_TD_CLASS,
- thAttr: { 'data-testid': 'status-th' },
- },
- {
- key: 'pipeline',
- label: __('Pipeline'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS}`,
- columnClass: 'gl-w-30p',
- thAttr: { 'data-testid': 'pipeline-th' },
- },
- {
- key: 'triggerer',
- label: s__('Pipeline|Triggerer'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`,
- columnClass: 'gl-w-10p',
- thAttr: { 'data-testid': 'triggerer-th' },
- },
- {
- key: 'stages',
- label: s__('Pipeline|Stages'),
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-quarter',
- thAttr: { 'data-testid': 'stages-th' },
- },
- {
- key: 'actions',
- thClass: DEFAULT_TH_CLASSES,
- tdClass: DEFAULT_TD_CLASS,
- columnClass: 'gl-w-15p',
- thAttr: { 'data-testid': 'actions-th' },
- },
- ],
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [Tracking.mixin()],
+ mixins: [Tracking.mixin(), glFeatureFlagMixin()],
+ inject: {
+ withFailedJobsDetails: {
+ default: false,
+ },
+ },
props: {
pipelines: {
type: Array,
@@ -104,6 +70,63 @@ export default {
cancelingPipeline: null,
};
},
+ computed: {
+ tableFields() {
+ return [
+ {
+ key: 'status',
+ label: s__('Pipeline|Status'),
+ thClass: DEFAULT_TH_CLASSES,
+ columnClass: 'gl-w-15p',
+ tdClass: this.tdClasses,
+ thAttr: { 'data-testid': 'status-th' },
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${this.tdClasses}`,
+ columnClass: 'gl-w-30p',
+ thAttr: { 'data-testid': 'pipeline-th' },
+ },
+ {
+ key: 'triggerer',
+ label: s__('Pipeline|Triggerer'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: `${this.tdClasses} ${HIDE_TD_ON_MOBILE}`,
+ columnClass: 'gl-w-10p',
+ thAttr: { 'data-testid': 'triggerer-th' },
+ },
+ {
+ key: 'stages',
+ label: s__('Pipeline|Stages'),
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: this.tdClasses,
+ columnClass: 'gl-w-quarter',
+ thAttr: { 'data-testid': 'stages-th' },
+ },
+ {
+ key: 'actions',
+ thClass: DEFAULT_TH_CLASSES,
+ tdClass: this.tdClasses,
+ columnClass: 'gl-w-15p',
+ thAttr: { 'data-testid': 'actions-th' },
+ },
+ ];
+ },
+ tdClasses() {
+ return this.withFailedJobsDetails ? 'gl-pb-0! gl-border-none!' : 'pl-p-5!';
+ },
+ pipelinesWithDetails() {
+ if (this.withFailedJobsDetails) {
+ return this.pipelines.map((p) => {
+ return { ...p, _showDetails: true };
+ });
+ }
+
+ return this.pipelines;
+ },
+ },
watch: {
pipelines() {
this.cancelingPipeline = null;
@@ -120,11 +143,17 @@ export default {
const downstream = pipeline.triggered;
return keepLatestDownstreamPipelines(downstream);
},
+ hasFailedJobs(pipeline) {
+ return pipeline?.failed_builds?.length > 0 || false;
+ },
setModalData(data) {
this.pipelineId = data.pipeline.id;
this.pipeline = data.pipeline;
this.endpoint = data.endpoint;
},
+ showFailedJobsWidget(item) {
+ return this.glFeatures.ciJobFailuresInMr && this.hasFailedJobs(item);
+ },
onSubmit() {
eventHub.$emit('postAction', this.endpoint);
this.cancelingPipeline = this.pipelineId;
@@ -142,9 +171,8 @@ export default {
<template>
<div class="ci-table">
<gl-table-lite
- :fields="$options.tableFields"
- :items="pipelines"
- tbody-tr-class="commit"
+ :fields="tableFields"
+ :items="pipelinesWithDetails"
:tbody-tr-attr="$options.TBODY_TR_ATTR"
stacked="lg"
fixed
@@ -167,6 +195,7 @@ export default {
:pipeline="item"
:pipeline-schedule-url="pipelineScheduleUrl"
:pipeline-key="pipelineKeyOption.value"
+ ref-color="gl-text-black-normal"
/>
</template>
@@ -188,6 +217,14 @@ export default {
<template #cell(actions)="{ item }">
<pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" />
</template>
+
+ <template #row-details="{ item }">
+ <pipeline-failed-jobs-widget
+ v-if="showFailedJobsWidget(item)"
+ :pipeline-iid="item.iid"
+ :pipeline-path="item.path"
+ />
+ </template>
</gl-table-lite>
<pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index d068eb16ed4..bdecbb88a58 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -14,6 +14,17 @@ export default {
type: Object,
required: true,
},
+ displayCalendarIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ fontSize: {
+ type: String,
+ required: false,
+ default: 'gl-font-sm',
+ validator: (fontSize) => ['gl-font-sm', 'gl-font-md'].includes(fontSize),
+ },
},
computed: {
duration() {
@@ -23,47 +34,29 @@ export default {
return formatTime(this.duration * 1000);
},
finishedTime() {
- return this.pipeline?.details?.finished_at;
- },
- showInProgress() {
- return !this.duration && !this.finishedTime && !this.skipped;
- },
- showSkipped() {
- return !this.duration && !this.finishedTime && this.skipped;
- },
- skipped() {
- return this.pipeline?.details?.status?.label === 'skipped';
- },
- stuck() {
- return this.pipeline.flags.stuck;
+ return this.pipeline?.details?.finished_at || this.pipeline?.finishedAt;
},
},
};
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago">
- <span
- v-if="showInProgress"
- class="gl-display-inline-flex gl-align-items-center"
- data-testid="pipeline-in-progress"
- >
- <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
- <gl-icon v-else name="hourglass" class="gl-mr-2" :size="12" data-testid="hourglass-icon" />
- {{ s__('Pipeline|In progress') }}
- </span>
-
- <span v-if="showSkipped" data-testid="pipeline-skipped">
- <gl-icon name="status_skipped_borderless" />
- {{ s__('Pipeline|Skipped') }}
- </span>
-
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-flex-end gl-lg-align-items-flex-start"
+ :class="fontSize"
+ >
<p v-if="duration" class="duration gl-display-inline-flex gl-align-items-center">
<gl-icon name="timer" class="gl-mr-2" :size="12" />
{{ durationFormatted }}
</p>
<p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center">
- <gl-icon name="calendar" class="gl-mr-2" :size="12" />
+ <gl-icon
+ v-if="displayCalendarIcon"
+ name="calendar"
+ class="gl-mr-2"
+ :size="12"
+ data-testid="calendar-icon"
+ />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index d092c3ca630..a6dd835bb15 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -110,3 +110,7 @@ export const TRACKING_CATEGORIES = {
tabs: 'pipelines_filter_tabs',
search: 'pipelines_filtered_search',
};
+
+// Pipeline Mini Graph
+
+export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000;
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
index 5bdafa15f72..c1f994ece24 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
@@ -3,7 +3,7 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
id
pipeline(iid: $pipelineIid) {
id
- jobs(statuses: FAILED, retried: false) {
+ jobs(statuses: FAILED, retried: false, jobKind: BUILD) {
nodes {
status
detailedStatus {
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql
index 9257cc7de7b..9257cc7de7b 100644
--- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_linked_pipelines.query.graphql
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
new file mode 100644
index 00000000000..2c842f1ac77
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql
@@ -0,0 +1,34 @@
+query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $pipelineIid) {
+ id
+ jobs(statuses: [FAILED], retried: false, jobKind: BUILD) {
+ nodes {
+ id
+ allowFailure
+ detailedStatus {
+ id
+ group
+ icon
+ action {
+ id
+ path
+ icon
+ }
+ }
+ name
+ retried
+ stage {
+ id
+ name
+ }
+ trace {
+ htmlSummary
+ }
+ webPath
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
index 47bc167ca52..eb5643126a2 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -32,6 +32,15 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
emoji
}
}
+ commit {
+ id
+ shortId
+ title
+ webPath
+ }
+ finishedAt
+ queuedDuration
+ duration
}
}
}
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql
index 69a29947b16..69a29947b16 100644
--- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_stages.query.graphql
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 61847affa1f..5b9bfd53b13 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,27 +2,33 @@ import VueRouter from 'vue-router';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { pipelineTabName } from './constants';
-import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineHeaderApp, createPipelineDetailsHeaderApp } from './pipeline_details_header';
import { apolloProvider } from './pipeline_shared_client';
const SELECTORS = {
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_DETAILS_HEADER: '#js-pipeline-details-header-vue',
PIPELINE_TABS: '#js-pipeline-tabs',
};
-export default async function initPipelineDetailsBundle() {
- const { dataset: headerDataset } = document.querySelector(SELECTORS.PIPELINE_HEADER);
-
- try {
- createPipelineHeaderApp(
- SELECTORS.PIPELINE_HEADER,
- apolloProvider,
- headerDataset.graphqlResourceEtag,
- );
- } catch {
- createAlert({
- message: __('An error occurred while loading a section of this page.'),
- });
+export default async function initPipelineDetailsBundle(flagEnabled) {
+ const headerSelector = flagEnabled
+ ? SELECTORS.PIPELINE_DETAILS_HEADER
+ : SELECTORS.PIPELINE_HEADER;
+ const headerApp = flagEnabled ? createPipelineDetailsHeaderApp : createPipelineHeaderApp;
+
+ const headerEl = document.querySelector(headerSelector);
+
+ if (headerEl) {
+ const { dataset: headerDataset } = headerEl;
+
+ try {
+ headerApp(headerSelector, apolloProvider, headerDataset.graphqlResourceEtag);
+ } catch {
+ createAlert({
+ message: __('An error occurred while loading a section of this page.'),
+ });
+ }
}
const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index c9e60756407..807ef225edd 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { parseBoolean } from '~/lib/utils/common_utils';
import PipelineHeader from './components/header_component.vue';
+import PipelineDetailsHeader from './components/pipeline_details_header.vue';
Vue.use(VueApollo);
@@ -33,3 +35,72 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
},
});
};
+
+export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const {
+ fullPath,
+ pipelineIid,
+ pipelinesPath,
+ name,
+ totalJobs,
+ computeCredits,
+ yamlErrors,
+ failureReason,
+ triggeredByPath,
+ schedule,
+ child,
+ latest,
+ mergeTrainPipeline,
+ invalid,
+ failed,
+ autoDevops,
+ detached,
+ stuck,
+ refText,
+ } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'PipelineDetailsHeaderApp',
+ apolloProvider,
+ provide: {
+ paths: {
+ fullProject: fullPath,
+ graphqlResourceEtag,
+ pipelinesPath,
+ triggeredByPath,
+ },
+ pipelineIid,
+ },
+ render(createElement) {
+ return createElement(PipelineDetailsHeader, {
+ props: {
+ name,
+ totalJobs,
+ computeCredits,
+ yamlErrors,
+ failureReason,
+ refText,
+ badges: {
+ schedule: parseBoolean(schedule),
+ child: parseBoolean(child),
+ latest: parseBoolean(latest),
+ mergeTrainPipeline: parseBoolean(mergeTrainPipeline),
+ invalid: parseBoolean(invalid),
+ failed: parseBoolean(failed),
+ autoDevops: parseBoolean(autoDevops),
+ detached: parseBoolean(detached),
+ stuck: parseBoolean(stuck),
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 49e2e1644e2..20fd0915e28 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -48,6 +48,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
iosRunnersAvailable,
registrationToken,
fullPath,
+ visibilityPipelineIdType,
} = el.dataset;
return new Vue({
@@ -91,6 +92,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
defaultBranchName,
params: JSON.parse(params),
registrationToken,
+ defaultVisibilityPipelineIdType: visibilityPipelineIdType,
},
});
},
diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue
new file mode 100644
index 00000000000..7bab8a1c30d
--- /dev/null
+++ b/app/assets/javascripts/profile/components/follow.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import { DEFAULT_PER_PAGE } from '~/api';
+import { NEXT, PREV } from '~/vue_shared/components/pagination/constants';
+
+export default {
+ i18n: {
+ prev: PREV,
+ next: NEXT,
+ },
+ components: {
+ GlAvatarLabeled,
+ GlAvatarLink,
+ GlLoadingIcon,
+ GlPagination,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * avatar_url: string;
+ * id: number;
+ * name: string;
+ * state: string;
+ * username: string;
+ * web_url: string;
+ * }[]
+ */
+ users: {
+ type: Array,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ page: {
+ type: Number,
+ required: true,
+ },
+ totalItems: {
+ type: Number,
+ required: true,
+ },
+ perPage: {
+ type: Number,
+ required: false,
+ default: DEFAULT_PER_PAGE,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" />
+ <div v-else>
+ <div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap">
+ <div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p">
+ <gl-avatar-link
+ :href="user.web_url"
+ class="js-user-link gl-border gl-rounded-base gl-w-full gl-p-5"
+ :data-user-id="user.id"
+ :data-username="user.username"
+ >
+ <gl-avatar-labeled
+ :src="user.avatar_url"
+ :size="48"
+ :entity-id="user.id"
+ :entity-name="user.name"
+ :label="user.name"
+ :sub-label="user.username"
+ />
+ </gl-avatar-link>
+ </div>
+ </div>
+ <gl-pagination
+ align="center"
+ class="gl-mt-5"
+ :value="page"
+ :total-items="totalItems"
+ :per-page="perPage"
+ :prev-text="$options.i18n.prev"
+ :next-text="$options.i18n.next"
+ @input="$emit('pagination-input', $event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue
index 5b69f835294..1fa579bc611 100644
--- a/app/assets/javascripts/profile/components/followers_tab.vue
+++ b/app/assets/javascripts/profile/components/followers_tab.vue
@@ -1,16 +1,59 @@
<script>
import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getUserFollowers } from '~/rest_api';
+import { createAlert } from '~/alert';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import Follow from './follow.vue';
export default {
i18n: {
title: s__('UserProfile|Followers'),
+ errorMessage: s__(
+ 'UserProfile|An error occurred loading the followers. Please refresh the page to try again.',
+ ),
},
components: {
GlBadge,
GlTab,
+ Follow,
+ },
+ inject: ['followersCount', 'userId'],
+ data() {
+ return {
+ followers: [],
+ loading: true,
+ totalItems: 0,
+ page: 1,
+ };
+ },
+ watch: {
+ page: {
+ async handler() {
+ this.loading = true;
+
+ try {
+ const { data: followers, headers } = await getUserFollowers(this.userId, {
+ page: this.page,
+ });
+ const { total } = parseIntPagination(normalizeHeaders(headers));
+
+ this.followers = followers;
+ this.totalItems = total;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
+ } finally {
+ this.loading = false;
+ }
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ onPaginationInput(page) {
+ this.page = page;
+ },
},
- inject: ['followers'],
};
</script>
@@ -18,7 +61,14 @@ export default {
<gl-tab>
<template #title>
<span>{{ $options.i18n.title }}</span>
- <gl-badge size="sm" class="gl-ml-2">{{ followers }}</gl-badge>
+ <gl-badge size="sm" class="gl-ml-2">{{ followersCount }}</gl-badge>
</template>
+ <follow
+ :users="followers"
+ :loading="loading"
+ :page="page"
+ :total-items="totalItems"
+ @pagination-input="onPaginationInput"
+ />
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue
index d39d15a08f3..8ee878e3dcc 100644
--- a/app/assets/javascripts/profile/components/following_tab.vue
+++ b/app/assets/javascripts/profile/components/following_tab.vue
@@ -10,7 +10,7 @@ export default {
GlBadge,
GlTab,
},
- inject: ['followees'],
+ inject: ['followeesCount'],
};
</script>
@@ -18,7 +18,7 @@ export default {
<gl-tab>
<template #title>
<span>{{ $options.i18n.title }}</span>
- <gl-badge size="sm" class="gl-ml-2">{{ followees }}</gl-badge>
+ <gl-badge size="sm" class="gl-ml-2">{{ followeesCount }}</gl-badge>
</template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql b/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql
new file mode 100644
index 00000000000..ec743b8747f
--- /dev/null
+++ b/app/assets/javascripts/profile/components/graphql/get_user_snippets.query.graphql
@@ -0,0 +1,39 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query getUserSnippets(
+ $id: UserID!
+ $first: Int
+ $last: Int
+ $afterToken: String
+ $beforeToken: String
+) {
+ user(id: $id) {
+ id
+ avatarUrl
+ name
+ username
+ snippets(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ id
+ title
+ webUrl
+ visibilityLevel
+ createdAt
+ updatedAt
+ blobs {
+ nodes {
+ name
+ }
+ }
+ notes {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index 21f8a2d3500..8cfa3fb3eea 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,16 +1,24 @@
<script>
import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import ContributionEvents from '~/contribution_events/components/contribution_events.vue';
import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
personalProjects: s__('UserProfile|Personal projects'),
+ activity: s__('UserProfile|Activity'),
viewAll: s__('UserProfile|View all'),
+ eventsErrorMessage: s__(
+ 'UserProfile|An error occurred loading the activity. Please refresh the page to try again.',
+ ),
},
- components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList },
+ components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList, ContributionEvents },
+ inject: ['userActivityPath'],
props: {
personalProjects: {
type: Array,
@@ -21,15 +29,44 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ events: [],
+ eventsLoading: false,
+ };
+ },
+ async mounted() {
+ this.eventsLoading = true;
+
+ try {
+ const { data: events } = await axios.get(this.userActivityPath, {
+ params: { limit: 10 },
+ });
+ this.events = events;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.eventsErrorMessage, error, captureError: true });
+ } finally {
+ this.eventsLoading = false;
+ }
+ },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
<activity-calendar />
- <div class="gl-mx-n3 gl-display-flex gl-flex-wrap">
- <div class="gl-px-3 gl-w-full gl-lg-w-half"></div>
- <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
+ <div class="gl-mx-n5 gl-display-flex gl-flex-wrap">
+ <div class="gl-px-5 gl-w-full gl-lg-w-half" data-testid="activity-section">
+ <div
+ class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <h4 class="gl-flex-grow-1">{{ $options.i18n.activity }}</h4>
+ <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
+ </div>
+ <gl-loading-icon v-if="eventsLoading" class="gl-mt-5" size="md" />
+ <contribution-events v-else :events="events" />
+ </div>
+ <div class="gl-px-5 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
<div
class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index 8e52a98803d..3a30c3bdc9b 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -11,7 +11,7 @@ import GroupsTab from './groups_tab.vue';
import ContributedProjectsTab from './contributed_projects_tab.vue';
import PersonalProjectsTab from './personal_projects_tab.vue';
import StarredProjectsTab from './starred_projects_tab.vue';
-import SnippetsTab from './snippets_tab.vue';
+import SnippetsTab from './snippets/snippets_tab.vue';
import FollowersTab from './followers_tab.vue';
import FollowingTab from './following_tab.vue';
@@ -91,7 +91,7 @@ export default {
</script>
<template>
- <gl-tabs nav-class="gl-bg-gray-10" align="center">
+ <gl-tabs nav-class="gl-bg-gray-10" content-class="gl-bg-white gl-pt-5" align="center">
<component
:is="component"
v-for="{ key, component } in $options.tabs"
diff --git a/app/assets/javascripts/profile/components/snippets/snippet_row.vue b/app/assets/javascripts/profile/components/snippets/snippet_row.vue
new file mode 100644
index 00000000000..19e0e9dc7fd
--- /dev/null
+++ b/app/assets/javascripts/profile/components/snippets/snippet_row.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlAvatar, GlLink, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf, n__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { SNIPPET_VISIBILITY } from '~/snippets/constants';
+
+export default {
+ name: 'SnippetRow',
+ i18n: {
+ snippetInfo: s__('UserProfile|%{id} · created %{created} by %{author}'),
+ updatedInfo: s__('UserProfile|updated %{updated}'),
+ blobTooltip: s__('UserProfile|%{count} %{file}'),
+ },
+ components: {
+ GlAvatar,
+ GlLink,
+ GlSprintf,
+ GlIcon,
+ TimeAgo,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ snippet: {
+ type: Object,
+ required: true,
+ },
+ userInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ formattedId() {
+ return `$${getIdFromGraphQLId(this.snippet.id)}`;
+ },
+ profilePath() {
+ return `${gon.relative_url_root || ''}/${this.userInfo.username}`;
+ },
+ blobCount() {
+ return this.snippet.blobs?.nodes?.length || 0;
+ },
+ commentsCount() {
+ return this.snippet.notes?.nodes?.length || 0;
+ },
+ visibilityIcon() {
+ return SNIPPET_VISIBILITY[this.snippet.visibilityLevel]?.icon;
+ },
+ blobTooltip() {
+ return sprintf(this.$options.i18n.blobTooltip, {
+ count: this.blobCount,
+ file: n__('file', 'files', this.blobCount),
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-py-5">
+ <gl-avatar :size="48" :src="userInfo.avatarUrl" class="gl-mr-3" />
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-flex-start">
+ <gl-link
+ data-testid="snippet-url"
+ :href="snippet.webUrl"
+ class="gl-text-gray-900 gl-font-weight-bold gl-mb-2"
+ >{{ snippet.title }}</gl-link
+ >
+ <span class="gl-text-gray-500">
+ <gl-sprintf :message="$options.i18n.snippetInfo">
+ <template #id>
+ <span data-testid="snippet-id">{{ formattedId }}</span>
+ </template>
+ <template #created>
+ <time-ago data-testid="snippet-created-at" :time="snippet.createdAt" />
+ </template>
+ <template #author>
+ <gl-link data-testid="snippet-author" :href="profilePath" class="gl-text-gray-900">{{
+ userInfo.name
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ <div class="gl-ml-auto gl-display-flex gl-flex-direction-column gl-align-items-flex-end">
+ <div class="gl-display-flex gl-align-items-center gl-mb-2">
+ <span
+ v-gl-tooltip
+ data-testid="snippet-blob"
+ :title="blobTooltip"
+ class="gl-mr-4"
+ :class="{ 'gl-opacity-5': blobCount === 0 }"
+ >
+ <gl-icon name="documents" />
+ <span>{{ blobCount }}</span>
+ </span>
+ <gl-link
+ data-testid="snippet-comments"
+ :href="`${snippet.webUrl}#notes`"
+ class="gl-mr-4 gl-text-gray-900"
+ :class="{ 'gl-opacity-5': commentsCount === 0 }"
+ >
+ <gl-icon name="comments" />
+ <span>{{ commentsCount }}</span>
+ </gl-link>
+ <gl-icon data-testid="snippet-visibility" :name="visibilityIcon" />
+ </div>
+ <span class="gl-text-gray-500">
+ <gl-sprintf :message="$options.i18n.updatedInfo">
+ <template #updated>
+ <time-ago data-testid="snippet-updated-at" :time="snippet.updatedAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/components/snippets/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue
new file mode 100644
index 00000000000..fce5e2f5e78
--- /dev/null
+++ b/app/assets/javascripts/profile/components/snippets/snippets_tab.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlTab, GlKeysetPagination, GlEmptyState } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants';
+import getUserSnippets from '../graphql/get_user_snippets.query.graphql';
+import SnippetRow from './snippet_row.vue';
+
+export default {
+ name: 'SnippetsTab',
+ i18n: {
+ title: s__('UserProfile|Snippets'),
+ noSnippets: s__('UserProfiles|No snippets found.'),
+ },
+ components: {
+ GlTab,
+ GlKeysetPagination,
+ GlEmptyState,
+ SnippetRow,
+ },
+ inject: ['userId', 'snippetsEmptyState'],
+ data() {
+ return {
+ userInfo: {},
+ pageInfo: {},
+ cursor: {
+ first: SNIPPET_MAX_LIST_COUNT,
+ last: null,
+ },
+ };
+ },
+ apollo: {
+ userSnippets: {
+ query: getUserSnippets,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_USER, this.userId),
+ ...this.cursor,
+ };
+ },
+ update(data) {
+ this.userInfo = {
+ avatarUrl: data.user?.avatarUrl,
+ name: data.user?.name,
+ username: data.user?.username,
+ };
+ this.pageInfo = data?.user?.snippets?.pageInfo;
+ return data?.user?.snippets?.nodes || [];
+ },
+ error() {
+ return [];
+ },
+ },
+ },
+ computed: {
+ hasSnippets() {
+ return this.userSnippets?.length;
+ },
+ },
+ methods: {
+ isLastSnippet(index) {
+ return index === this.userSnippets.length - 1;
+ },
+ nextPage() {
+ this.cursor = {
+ first: SNIPPET_MAX_LIST_COUNT,
+ last: null,
+ afterToken: this.pageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: SNIPPET_MAX_LIST_COUNT,
+ beforeToken: this.pageInfo.startCursor,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <template v-if="hasSnippets">
+ <snippet-row
+ v-for="(snippet, index) in userSnippets"
+ :key="snippet.id"
+ :snippet="snippet"
+ :user-info="userInfo"
+ :class="{ 'gl-border-b': !isLastSnippet(index) }"
+ />
+ <div class="gl-display-flex gl-justify-content-center gl-mt-6">
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
+ </template>
+ <template v-if="!hasSnippets">
+ <gl-empty-state class="gl-mt-5" :svg-height="75" :svg-path="snippetsEmptyState">
+ <template #title>
+ <p class="gl-font-weight-bold gl-mt-n5">{{ $options.i18n.noSnippets }}</p>
+ </template>
+ </gl-empty-state>
+ </template>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets_tab.vue
deleted file mode 100644
index d64c5b900a5..00000000000
--- a/app/assets/javascripts/profile/components/snippets_tab.vue
+++ /dev/null
@@ -1,17 +0,0 @@
-<script>
-import { GlTab } from '@gitlab/ui';
-import { s__ } from '~/locale';
-
-export default {
- i18n: {
- title: s__('UserProfile|Snippets'),
- },
- components: { GlTab },
-};
-</script>
-
-<template>
- <gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
- </gl-tab>
-</template>
diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue
index fd42b64f4c5..13a1b797a83 100644
--- a/app/assets/javascripts/profile/components/user_achievements.vue
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -1,6 +1,7 @@
<script>
-import { GlPopover, GlSprintf } from '@gitlab/ui';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { GlAvatar, GlBadge, GlPopover, GlSprintf } from '@gitlab/ui';
+import { groupBy } from 'lodash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -8,7 +9,7 @@ import getUserAchievements from './graphql/get_user_achievements.query.graphql';
export default {
name: 'UserAchievements',
- components: { GlPopover, GlSprintf },
+ components: { GlAvatar, GlBadge, GlPopover, GlSprintf },
mixins: [timeagoMixin],
inject: ['rootUrl', 'userId'],
apollo: {
@@ -29,25 +30,39 @@ export default {
},
methods: {
processNodes(nodes) {
- return nodes.slice(0, 3).map(({ achievement, createdAt, achievement: { namespace } }) => {
- return {
- id: `user-achievement-${getIdFromGraphQLId(achievement.id)}`,
- name: achievement.name,
- timeAgo: this.timeFormatted(createdAt),
- avatarUrl: achievement.avatarUrl || gon.gitlab_logo,
- description: achievement.description,
- namespace: namespace && {
- fullPath: namespace.fullPath,
- webUrl: this.rootUrl + namespace.fullPath,
- },
- };
- });
+ return Object.entries(groupBy(nodes, 'achievement.id'))
+ .slice(0, 3)
+ .map(([id, values]) => {
+ const {
+ achievement: { name, avatarUrl, description, namespace },
+ createdAt,
+ } = values[0];
+ const count = values.length;
+ return {
+ id: `user-achievement-${id}`,
+ name,
+ timeAgo: this.timeFormatted(createdAt),
+ avatarUrl: avatarUrl || gon.gitlab_logo,
+ description,
+ namespace: namespace && {
+ fullPath: namespace.fullPath,
+ webUrl: this.rootUrl + namespace.fullPath,
+ },
+ count,
+ };
+ });
},
achievementAwardedMessage(userAchievement) {
return userAchievement.namespace
? this.$options.i18n.awardedBy
: this.$options.i18n.awardedByUnknownNamespace;
},
+ showCountBadge(count) {
+ return count > 1;
+ },
+ getCountBadge(count) {
+ return `${count}x`;
+ },
},
i18n: {
awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'),
@@ -61,18 +76,28 @@ export default {
<div
v-for="userAchievement in userAchievements"
:key="userAchievement.id"
- class="gl-display-inline-block"
+ class="gl-display-inline-block gl-vertical-align-top"
data-testid="user-achievement"
>
- <img
+ <gl-avatar
:id="userAchievement.id"
:src="userAchievement.avatarUrl"
- :alt="''"
+ :size="32"
tabindex="0"
- class="gl-avatar gl-avatar-s32 gl-mx-2"
+ shape="rect"
+ class="gl-mx-2"
/>
- <gl-popover triggers="hover focus" placement="top" :target="userAchievement.id">
- <div class="gl-font-weight-bold">{{ userAchievement.name }}</div>
+ <br />
+ <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{
+ getCountBadge(userAchievement.count)
+ }}</gl-badge>
+ <gl-popover :target="userAchievement.id">
+ <div>
+ <span class="gl-font-weight-bold">{{ userAchievement.name }}</span>
+ <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{
+ getCountBadge(userAchievement.count)
+ }}</gl-badge>
+ </div>
<div>
<gl-sprintf :message="achievementAwardedMessage(userAchievement)">
<template #timeAgo>
diff --git a/app/assets/javascripts/profile/constants.js b/app/assets/javascripts/profile/constants.js
index e19994c6784..9d3dcd648a8 100644
--- a/app/assets/javascripts/profile/constants.js
+++ b/app/assets/javascripts/profile/constants.js
@@ -5,3 +5,5 @@ export const CALENDAR_PERIOD_12_MONTHS = 12;
* (see activity_calendar.js)
*/
export const OVERVIEW_CALENDAR_BREAKPOINT = 918;
+
+export const SNIPPET_MAX_LIST_COUNT = 20;
diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
new file mode 100644
index 00000000000..ab29d94c41c
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
@@ -0,0 +1,10 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <!-- This is left empty intensionally -->
+ <!-- It will be implemented in the upcoming MRs -->
+ <!-- Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/389918 -->
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/profile/edit/index.js b/app/assets/javascripts/profile/edit/index.js
new file mode 100644
index 00000000000..b46a395d6f5
--- /dev/null
+++ b/app/assets/javascripts/profile/edit/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import ProfileEditApp from './components/profile_edit_app.vue';
+
+export const initProfileEdit = () => {
+ const mountEl = document.querySelector('.js-user-profile');
+
+ if (!mountEl) return false;
+
+ return new Vue({
+ el: mountEl,
+ name: 'ProfileEditRoot',
+ render(createElement) {
+ return createElement(ProfileEditApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index 101e52c873e..198ffdb434b 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -13,17 +13,32 @@ export const initProfileTabs = () => {
if (!el) return false;
- const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset;
+ const {
+ followeesCount,
+ followersCount,
+ userCalendarPath,
+ userActivityPath,
+ utcOffset,
+ userId,
+ snippetsEmptyState,
+ } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
return new Vue({
el,
+ apolloProvider,
name: 'ProfileRoot',
provide: {
- followees: parseInt(followers, 10),
- followers: parseInt(followees, 10),
+ followeesCount: parseInt(followeesCount, 10),
+ followersCount: parseInt(followersCount, 10),
userCalendarPath,
+ userActivityPath,
utcOffset,
userId,
+ snippetsEmptyState,
},
render(createElement) {
return createElement(ProfileTabs);
diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
index a4edc988d67..7c00ce45b3a 100644
--- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -1,14 +1,17 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui';
+import { GlDisclosureDropdownGroup, GlDisclosureDropdown } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '../constants';
import eventHub from '../event_hub';
export default {
+ i18n: {
+ gitlabTag: s__('CreateTag|Tag'),
+ },
+
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlDropdownSectionHeader,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
},
inject: {
newProjectTagPath: {
@@ -43,66 +46,117 @@ export default {
showDivider() {
return this.canRevert || this.canCherryPick || this.canTag;
},
+ cherryPickItem() {
+ return {
+ text: s__('ChangeTypeAction|Cherry-pick'),
+ extraAttrs: {
+ 'data-testid': 'cherry-pick-link',
+ 'data-qa-selector': 'cherry_pick_button',
+ },
+ action: () => this.showModal(OPEN_CHERRY_PICK_MODAL),
+ };
+ },
+
+ revertLinkItem() {
+ return {
+ text: s__('ChangeTypeAction|Revert'),
+ extraAttrs: {
+ 'data-testid': 'revert-link',
+ 'data-qa-selector': 'revert_button',
+ },
+ action: () => this.showModal(OPEN_REVERT_MODAL),
+ };
+ },
+
+ tagLinkItem() {
+ return {
+ text: s__('CreateTag|Tag'),
+ href: this.newProjectTagPath,
+ extraAttrs: {
+ 'data-testid': 'tag-link',
+ },
+ };
+ },
+ plainDiffItem() {
+ return {
+ text: s__('DownloadCommit|Plain Diff'),
+ href: this.plainDiffPath,
+ extraAttrs: {
+ download: '',
+ rel: 'nofollow',
+ 'data-testid': 'plain-diff-link',
+ 'data-qa-selector': 'plain_diff',
+ },
+ };
+ },
+ patchesItem() {
+ return {
+ text: __('Patches'),
+ href: this.emailPatchesPath,
+ extraAttrs: {
+ download: '',
+ rel: 'nofollow',
+ 'data-testid': 'email-patches-link',
+ 'data-qa-selector': 'email_patches',
+ },
+ };
+ },
+
+ downloadsGroup() {
+ const items = [];
+ if (this.canEmailPatches) {
+ items.push(this.patchesItem);
+ }
+ items.push(this.plainDiffItem);
+ return {
+ name: __('Downloads'),
+ items,
+ };
+ },
+
+ optionsGroup() {
+ const items = [];
+ if (this.canRevert) {
+ items.push(this.revertLinkItem);
+ }
+ if (this.canCherryPick) {
+ items.push(this.cherryPickItem);
+ }
+ if (this.canTag) {
+ items.push(this.tagLinkItem);
+ }
+ return {
+ items,
+ };
+ },
},
+
methods: {
showModal(modalId) {
eventHub.$emit(modalId);
},
+ closeDropdown() {
+ this.$refs.userDropdown.close();
+ },
},
- openRevertModal: OPEN_REVERT_MODAL,
- openCherryPickModal: OPEN_CHERRY_PICK_MODAL,
};
</script>
<template>
- <gl-dropdown
- :text="__('Options')"
+ <gl-disclosure-dropdown
+ ref="userDropdown"
+ :toggle-text="__('Options')"
right
data-testid="commit-options-dropdown"
data-qa-selector="options_button"
- class="gl-xs-w-full"
+ class="gl-xs-w-full gl-line-height-20"
>
- <gl-dropdown-item
- v-if="canRevert"
- data-testid="revert-link"
- data-qa-selector="revert_button"
- @click="showModal($options.openRevertModal)"
- >
- {{ s__('ChangeTypeAction|Revert') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="canCherryPick"
- data-testid="cherry-pick-link"
- data-qa-selector="cherry_pick_button"
- @click="showModal($options.openCherryPickModal)"
- >
- {{ s__('ChangeTypeAction|Cherry-pick') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canTag" :href="newProjectTagPath" data-testid="tag-link">
- {{ s__('CreateTag|Tag') }}
- </gl-dropdown-item>
- <gl-dropdown-divider v-if="showDivider" />
- <gl-dropdown-section-header>
- {{ __('Download') }}
- </gl-dropdown-section-header>
- <gl-dropdown-item
- v-if="canEmailPatches"
- :href="emailPatchesPath"
- download
- rel="nofollow"
- data-testid="email-patches-link"
- data-qa-selector="email_patches"
- >
- {{ __('Patches') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- :href="plainDiffPath"
- download
- rel="nofollow"
- data-testid="plain-diff-link"
- data-qa-selector="plain_diff"
- >
- {{ s__('DownloadCommit|Plain Diff') }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-group :group="optionsGroup" @action="closeDropdown" />
+
+ <gl-disclosure-dropdown-group
+ :bordered="showDivider"
+ :group="downloadsGroup"
+ @action="closeDropdown"
+ />
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 54d13ecc9c8..84e7edb48c1 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -7,10 +7,12 @@ import {
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
+import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
import { formatStages } from '../utils';
-import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
-import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql';
import { COMMIT_BOX_POLL_INTERVAL } from '../constants';
export default {
@@ -21,8 +23,10 @@ export default {
},
components: {
GlLoadingIcon,
+ GraphqlPipelineMiniGraph,
PipelineMiniGraph,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
fullPath: {
default: '',
@@ -47,15 +51,15 @@ export default {
},
query: getLinkedPipelinesQuery,
pollInterval: COMMIT_BOX_POLL_INTERVAL,
+ skip() {
+ return !this.fullPath || !this.iid || this.isUsingPipelineMiniGraphQueries;
+ },
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
- skip() {
- return !this.fullPath || !this.iid;
- },
update({ project }) {
return project?.pipeline;
},
@@ -69,6 +73,9 @@ export default {
},
query: getPipelineStagesQuery,
pollInterval: COMMIT_BOX_POLL_INTERVAL,
+ skip() {
+ return this.isUsingPipelineMiniGraphQueries;
+ },
variables() {
return {
fullPath: this.fullPath,
@@ -95,6 +102,9 @@ export default {
const downstream = this.pipeline?.downstream?.nodes;
return keepLatestDownstreamPipelines(downstream);
},
+ isUsingPipelineMiniGraphQueries() {
+ return this.glFeatures.ciGraphqlPipelineMiniGraph;
+ },
pipelinePath() {
return this.pipeline?.path ?? '';
},
@@ -128,13 +138,22 @@ export default {
<template>
<div>
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" />
- <pipeline-mini-graph
- v-else
- data-testid="commit-box-pipeline-mini-graph"
- :downstream-pipelines="downstreamPipelines"
- :pipeline-path="pipelinePath"
- :stages="formattedStages"
- :upstream-pipeline="upstreamPipeline"
- />
+ <template v-else>
+ <graphql-pipeline-mini-graph
+ v-if="isUsingPipelineMiniGraphQueries"
+ data-testid="commit-box-pipeline-mini-graph"
+ :pipeline-etag="graphqlResourceEtag"
+ :full-path="fullPath"
+ :iid="iid"
+ />
+ <pipeline-mini-graph
+ v-else
+ data-testid="commit-box-pipeline-mini-graph"
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="formattedStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue
new file mode 100644
index 00000000000..25af4cc8082
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_refs.vue
@@ -0,0 +1,134 @@
+<script>
+import { createAlert } from '~/alert';
+import { joinPaths } from '~/lib/utils/url_utility';
+import commitReferencesQuery from '../graphql/queries/commit_references.query.graphql';
+import containingBranchesQuery from '../graphql/queries/commit_containing_branches.query.graphql';
+import containingTagsQuery from '../graphql/queries/commit_containing_tags.query.graphql';
+import {
+ BRANCHES,
+ TAGS,
+ FETCH_CONTAINING_REFS_EVENT,
+ FETCH_COMMIT_REFERENCES_ERROR,
+ BRANCHES_REF_TYPE,
+ TAGS_REF_TYPE,
+} from '../constants';
+import RefsList from './refs_list.vue';
+
+export default {
+ name: 'CommitRefs',
+ components: {
+ RefsList,
+ },
+ inject: ['fullPath', 'commitSha'],
+ apollo: {
+ project: {
+ query: commitReferencesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update({
+ project: {
+ commitReferences: { tippingTags, tippingBranches, containingBranches, containingTags },
+ },
+ }) {
+ this.tippingTags = tippingTags.names;
+ this.tippingBranches = tippingBranches.names;
+ this.hasContainingBranches = Boolean(containingBranches.names.length);
+ this.hasContainingTags = Boolean(containingTags.names.length);
+ },
+ error() {
+ createAlert({
+ message: this.$options.i18n.errorMessage,
+ captureError: true,
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ containingTags: [],
+ containingBranches: [],
+ tippingTags: [],
+ tippingBranches: [],
+ hasContainingBranches: false,
+ hasContainingTags: false,
+ };
+ },
+ computed: {
+ hasBranches() {
+ return this.tippingBranches.length || this.hasContainingBranches;
+ },
+ hasTags() {
+ return this.tippingTags.length || this.hasContainingTags;
+ },
+ queryVariables() {
+ return {
+ fullPath: this.fullPath,
+ commitSha: this.commitSha,
+ };
+ },
+ commitsUrlPart() {
+ const urlPart = joinPaths(gon.relative_url_root || '', `/${this.fullPath}`, `/-/commits/`);
+ return urlPart;
+ },
+ },
+ methods: {
+ async fetchContainingRefs({ query, namespace }) {
+ try {
+ const { data } = await this.$apollo.query({
+ query,
+ variables: this.queryVariables,
+ });
+ this[namespace] = data.project.commitReferences[namespace].names;
+ return data.project.commitReferences[namespace].names;
+ } catch {
+ return createAlert({
+ message: this.$options.i18n.errorMessage,
+ captureError: true,
+ });
+ }
+ },
+ fetchContainingBranches() {
+ this.fetchContainingRefs({ query: containingBranchesQuery, namespace: 'containingBranches' });
+ },
+ fetchContainingTags() {
+ this.fetchContainingRefs({ query: containingTagsQuery, namespace: 'containingTags' });
+ },
+ },
+ i18n: {
+ branches: BRANCHES,
+ tags: TAGS,
+ errorMessage: FETCH_COMMIT_REFERENCES_ERROR,
+ },
+ FETCH_CONTAINING_REFS_EVENT,
+ BRANCHES_REF_TYPE,
+ TAGS_REF_TYPE,
+};
+</script>
+
+<template>
+ <div class="gl-ml-7">
+ <refs-list
+ v-if="hasBranches"
+ :has-containing-refs="hasContainingBranches"
+ :is-loading="$apollo.queries.project.loading"
+ :tipping-refs="tippingBranches"
+ :containing-refs="containingBranches"
+ :namespace="$options.i18n.branches"
+ :url-part="commitsUrlPart"
+ :ref-type="$options.BRANCHES_REF_TYPE"
+ @[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingBranches"
+ />
+ <refs-list
+ v-if="hasTags"
+ :has-containing-refs="hasContainingTags"
+ :is-loading="$apollo.queries.project.loading"
+ :tipping-refs="tippingTags"
+ :containing-refs="containingTags"
+ :namespace="$options.i18n.tags"
+ :url-part="commitsUrlPart"
+ :ref-type="$options.TAGS_REF_TYPE"
+ @[$options.FETCH_CONTAINING_REFS_EVENT]="fetchContainingTags"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue
new file mode 100644
index 00000000000..8ceab9cb60b
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/components/refs_list.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlCollapse, GlBadge, GlButton, GlIcon, GlSkeletonLoader } from '@gitlab/ui';
+import { CONTAINING_COMMIT, FETCH_CONTAINING_REFS_EVENT } from '../constants';
+
+export default {
+ name: 'RefsList',
+ components: {
+ GlCollapse,
+ GlSkeletonLoader,
+ GlBadge,
+ GlButton,
+ GlIcon,
+ },
+ props: {
+ urlPart: {
+ type: String,
+ required: true,
+ },
+ refType: {
+ type: String,
+ required: true,
+ },
+ containingRefs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ tippingRefs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ hasContainingRefs: {
+ type: Boolean,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isContainingRefsVisible: false,
+ };
+ },
+ computed: {
+ collapseIcon() {
+ return this.isContainingRefsVisible ? 'chevron-down' : 'chevron-right';
+ },
+ isLoadingRefs() {
+ return this.isLoading && !this.containingRefs.length;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isContainingRefsVisible = !this.isContainingRefsVisible;
+ },
+ showRefs() {
+ this.toggleCollapse();
+ this.$emit(FETCH_CONTAINING_REFS_EVENT);
+ },
+ getRefUrl(ref) {
+ return `${this.urlPart}${ref}?ref_type=${this.refType}`;
+ },
+ },
+ i18n: {
+ containingCommit: CONTAINING_COMMIT,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-pt-4">
+ <span data-testid="title" class="gl-mr-2">{{ namespace }}</span>
+ <gl-badge
+ v-for="ref in tippingRefs"
+ :key="ref"
+ :href="getRefUrl(ref)"
+ class="gl-mt-2 gl-mr-2"
+ size="sm"
+ >{{ ref }}</gl-badge
+ >
+ <gl-button
+ v-if="hasContainingRefs"
+ class="gl-mr-2 gl-font-sm!"
+ variant="link"
+ size="small"
+ @click="showRefs"
+ >
+ <gl-icon :name="collapseIcon" :size="14" />
+ {{ namespace }} {{ $options.i18n.containingCommit }}
+ </gl-button>
+ <gl-collapse :visible="isContainingRefsVisible">
+ <gl-skeleton-loader v-if="isLoadingRefs" :lines="1" />
+ <template v-else>
+ <gl-badge
+ v-for="ref in containingRefs"
+ :key="ref"
+ :href="getRefUrl(ref)"
+ class="gl-mt-3 gl-mr-2"
+ size="sm"
+ >{{ ref }}</gl-badge
+ >
+ </template>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/commit_box/info/constants.js b/app/assets/javascripts/projects/commit_box/info/constants.js
index be0bf715314..4b74fbe19e1 100644
--- a/app/assets/javascripts/projects/commit_box/info/constants.js
+++ b/app/assets/javascripts/projects/commit_box/info/constants.js
@@ -1,7 +1,23 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export const COMMIT_BOX_POLL_INTERVAL = 10000;
export const PIPELINE_STATUS_FETCH_ERROR = __(
'There was a problem fetching the latest pipeline status.',
);
+
+export const BRANCHES = s__('Commit|Branches');
+
+export const TAGS = s__('Commit|Tags');
+
+export const CONTAINING_COMMIT = s__('Commit|containing commit');
+
+export const FETCH_CONTAINING_REFS_EVENT = 'fetch-containing-refs';
+
+export const FETCH_COMMIT_REFERENCES_ERROR = s__(
+ 'Commit|There was an error fetching the commit references. Please try again later.',
+);
+
+export const BRANCHES_REF_TYPE = 'heads';
+
+export const TAGS_REF_TYPE = 'tags';
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql
new file mode 100644
index 00000000000..ea74efdbc46
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql
@@ -0,0 +1,10 @@
+query CommitContainingBranches($fullPath: ID!, $commitSha: String!) {
+ project(fullPath: $fullPath) {
+ id
+ commitReferences(commitSha: $commitSha) {
+ containingBranches(excludeTipped: true) {
+ names
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql
new file mode 100644
index 00000000000..d736dc3ab66
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_containing_tags.query.graphql
@@ -0,0 +1,10 @@
+query CommitContainingTags($fullPath: ID!, $commitSha: String!) {
+ project(fullPath: $fullPath) {
+ id
+ commitReferences(commitSha: $commitSha) {
+ containingTags(excludeTipped: true) {
+ names
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql
new file mode 100644
index 00000000000..71d911c2acc
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/commit_references.query.graphql
@@ -0,0 +1,19 @@
+query CommitReferences($fullPath: ID!, $commitSha: String!) {
+ project(fullPath: $fullPath) {
+ id
+ commitReferences(commitSha: $commitSha) {
+ containingBranches(excludeTipped: true, limit: 1) {
+ names
+ }
+ containingTags(excludeTipped: true, limit: 1) {
+ names
+ }
+ tippingBranches {
+ names
+ }
+ tippingTags {
+ names
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 7c4b76fd62f..8f09c8e1e11 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,12 +1,10 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
-import { loadBranches } from './load_branches';
import initCommitPipelineStatus from './init_commit_pipeline_status';
+import initCommitReferences from './init_commit_references';
export const initCommitBoxInfo = () => {
// Display commit related branches
- loadBranches();
-
// Related merge requests to this commit
fetchCommitMergeRequests();
@@ -14,4 +12,6 @@ export const initCommitBoxInfo = () => {
initCommitPipelineMiniGraph();
initCommitPipelineStatus();
+
+ initCommitReferences();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_references.js b/app/assets/javascripts/projects/commit_box/info/init_commit_references.js
new file mode 100644
index 00000000000..c8497187211
--- /dev/null
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_references.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import CommitBranches from './components/commit_refs.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default (selector = 'js-commit-branches-and-tags') => {
+ const el = document.getElementById(selector);
+
+ if (!el) {
+ return false;
+ }
+
+ const { fullPath, commitSha } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ fullPath,
+ commitSha,
+ },
+ render(createElement) {
+ return createElement(CommitBranches);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
deleted file mode 100644
index 8333e70b951..00000000000
--- a/app/assets/javascripts/projects/commit_box/info/load_branches.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import axios from 'axios';
-import { sanitize } from '~/lib/dompurify';
-import { __ } from '~/locale';
-import { initDetailsButton } from './init_details_button';
-
-export const loadBranches = (containerSelector = '.js-commit-box-info') => {
- const containerEl = document.querySelector(containerSelector);
- if (!containerEl) {
- return;
- }
-
- const { commitPath } = containerEl.dataset;
- const branchesEl = containerEl.querySelector('.commit-info.branches');
- axios
- .get(commitPath)
- .then(({ data }) => {
- branchesEl.innerHTML = sanitize(data);
-
- initDetailsButton();
- })
- .catch(() => {
- branchesEl.textContent = __('Failed to load branches. Please try again.');
- });
-};
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
index c00e75db722..4c0b5d0b1f6 100644
--- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -1,11 +1,9 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
props: {
paramsName: {
@@ -25,6 +23,7 @@ export default {
data() {
return {
searchTerm: '',
+ selectedProjectId: this.selectedProject.id,
};
},
computed: {
@@ -32,49 +31,45 @@ export default {
return this.projects === null;
},
filteredRepos() {
- const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
+ if (this.disableRepoDropdown) return [];
- return this?.projects.filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm));
+ const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
+ return this.projects
+ .filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm))
+ .map((project) => ({ text: project.name, value: project.id }));
},
inputName() {
return `${this.paramsName}_project_id`;
},
},
methods: {
- onClick(project) {
- this.emitTargetProject(project);
- },
- emitTargetProject(project) {
+ emitTargetProject(projectId) {
+ if (this.disableRepoDropdown) return;
+ const project = this.projects.find(({ id }) => id === projectId);
this.$emit('selectProject', { direction: this.paramsName, project });
},
+ onSearch(searchTerm) {
+ this.searchTerm = searchTerm;
+ },
},
};
</script>
<template>
<div>
- <input type="hidden" :name="inputName" :value="selectedProject.id" />
- <gl-dropdown
- :text="selectedProject.name"
+ <input type="hidden" :name="inputName" :value="selectedProjectId" />
+ <gl-collapsible-listbox
+ v-model="selectedProjectId"
+ :toggle-text="selectedProject.name"
:header-text="s__(`CompareRevisions|Select target project`)"
- class="gl-w-full gl-font-monospace"
+ class="gl-font-monospace"
toggle-class="gl-min-w-0"
:disabled="disableRepoDropdown"
- >
- <template #header>
- <gl-search-box-by-type v-if="!disableRepoDropdown" v-model.trim="searchTerm" />
- </template>
- <template v-if="!disableRepoDropdown">
- <gl-dropdown-item
- v-for="repo in filteredRepos"
- :key="repo.id"
- is-check-item
- :is-checked="selectedProject.id === repo.id"
- @click="onClick(repo)"
- >
- {{ repo.name }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
+ :items="filteredRepos"
+ block
+ searchable
+ @select="emitTargetProject"
+ @search="onSearch"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 2f58d4468be..6ca83b0b500 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -1,8 +1,8 @@
<script>
-import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg';
-import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg';
-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 createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg?raw';
+import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg?raw';
+import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg?raw';
+import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg?raw';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 99ea02aaa4f..33320f59b0f 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -295,7 +295,7 @@ const bindEvents = () => {
});
$newProjectForm.on('submit', () => {
- $projectPath.val($projectPath.val().trim());
+ $projectPath.value = $projectPath.value.trim();
});
const updateUrlPathWarningVisibility = async () => {
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
index 3dcacf9eb34..6494456d560 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
@@ -55,7 +55,7 @@ export default {
},
searchInputDelay: 250,
wildcardsHelpPath: helpPagePath('user/project/protected_branches', {
- anchor: 'configure-multiple-protected-branches-by-using-a-wildcard',
+ anchor: 'protect-multiple-branches-with-wildcard-rules',
}),
props: {
projectPath: {
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
index b71c33d2b91..a45ed5c68af 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
@@ -49,7 +49,7 @@ export const BRANCH_PARAM_NAME = 'branch';
export const ALL_BRANCHES_WILDCARD = '*';
export const WILDCARDS_HELP_PATH =
- 'user/project/protected_branches#configure-multiple-protected-branches-by-using-a-wildcard';
+ 'user/project/protected_branches#protect-multiple-branches-with-wildcard-rules';
export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches';
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index 08a1c586f69..a2e4827cbfa 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -63,6 +63,11 @@ export default {
required: false,
default: () => [],
},
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -143,11 +148,37 @@ export default {
query: debounce(function debouncedSearch() {
return this.getData();
}, 500),
+ items(items) {
+ this.setDataForSave(items);
+ },
},
created() {
this.getData({ initial: true });
},
methods: {
+ setDataForSave(items) {
+ this.selected = items.reduce(
+ (selected, item) => {
+ if (item.group_id) {
+ selected[LEVEL_TYPES.GROUP].push({ id: item.group_id, ...item });
+ } else if (item.user_id) {
+ selected[LEVEL_TYPES.USER].push({ id: item.user_id, ...item });
+ } else if (item.access_level) {
+ const level = this.accessLevelsData.find(({ id }) => item.access_level === id);
+ selected[LEVEL_TYPES.ROLE].push(level);
+ } else if (item.deploy_key_id) {
+ selected[LEVEL_TYPES.DEPLOY_KEY].push({ id: item.deploy_key_id, ...item });
+ }
+ return selected;
+ },
+ {
+ [LEVEL_TYPES.GROUP]: [],
+ [LEVEL_TYPES.USER]: [],
+ [LEVEL_TYPES.ROLE]: [],
+ [LEVEL_TYPES.DEPLOY_KEY]: [],
+ },
+ );
+ },
focusInput() {
this.$refs.search.focusInput();
},
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 79ece99e6ec..650b60cba4f 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
@@ -23,6 +23,9 @@ export default {
initialIsEnabled: {
default: false,
},
+ isIssueTrackerEnabled: {
+ default: false,
+ },
endpoint: {
default: '',
},
@@ -163,6 +166,7 @@ export default {
</gl-alert>
<service-desk-setting
:is-enabled="isEnabled"
+ :is-issue-tracker-enabled="isIssueTrackerEnabled"
:incoming-email="incomingEmail"
:custom-email="updatedCustomEmail"
:custom-email-enabled="customEmailEnabled"
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 5a3930b5df4..38a2c12d137 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
@@ -8,6 +8,7 @@ import {
GlFormGroup,
GlFormInput,
GlLink,
+ GlAlert,
} from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
@@ -17,6 +18,9 @@ import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue';
export default {
i18n: {
toggleLabel: __('Activate Service Desk'),
+ issueTrackerEnableMessage: __(
+ 'To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}.',
+ ),
},
components: {
ClipboardButton,
@@ -28,6 +32,7 @@ export default {
GlFormGroup,
GlFormInputGroup,
GlLink,
+ GlAlert,
ServiceDeskTemplateDropdown,
},
props: {
@@ -35,6 +40,10 @@ export default {
type: Boolean,
required: true,
},
+ isIssueTrackerEnabled: {
+ type: Boolean,
+ required: true,
+ },
incomingEmail: {
type: String,
required: false,
@@ -110,6 +119,11 @@ export default {
anchor: 'use-a-custom-email-address',
});
},
+ issuesHelpPagePath() {
+ return helpPagePath('user/project/settings/index.md', {
+ anchor: 'configure-project-visibility-features-and-permissions',
+ });
+ },
},
methods: {
onCheckboxToggle(isChecked) {
@@ -141,9 +155,24 @@ export default {
<template>
<div>
+ <gl-alert v-if="!isIssueTrackerEnabled" class="mb-3" variant="info" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.issueTrackerEnableMessage">
+ <template #link="{ content }">
+ <gl-link
+ class="gl-display-inline-block"
+ data-testid="issue-help-page"
+ :href="issuesHelpPagePath"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<gl-toggle
id="service-desk-checkbox"
:value="isEnabled"
+ :disabled="!isIssueTrackerEnabled"
class="d-inline-block align-middle mr-1"
:label="$options.i18n.toggleLabel"
label-position="hidden"
@@ -194,6 +223,7 @@ export default {
:label="__('Email address suffix')"
:state="!projectKeyError"
data-testid="suffix-form-group"
+ :disabled="!isIssueTrackerEnabled"
>
<gl-form-input
v-if="hasProjectKeySupport"
@@ -249,6 +279,7 @@ export default {
:label="__('Template to append to all Service Desk issues')"
:state="!projectKeyError"
class="mt-3"
+ :disabled="!isIssueTrackerEnabled"
>
<service-desk-template-dropdown
:selected-template="selectedTemplate"
@@ -268,6 +299,7 @@ export default {
id="service-desk-email-from-name"
v-model.trim="outgoingName"
data-testid="email-from-name"
+ :disabled="!isIssueTrackerEnabled"
/>
<template #description>
@@ -280,7 +312,7 @@ export default {
class="gl-mt-5"
data-testid="save_service_desk_settings_button"
data-qa-selector="save_service_desk_settings_button"
- :disabled="isTemplateSaving"
+ :disabled="isTemplateSaving || !isIssueTrackerEnabled"
@click="onSaveTemplate"
>
{{ __('Save changes') }}
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index 26435a5fac9..84229175c0b 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -13,6 +13,7 @@ export default () => {
customEmail,
customEmailEnabled,
enabled,
+ issueTrackerEnabled,
endpoint,
incomingEmail,
outgoingName,
@@ -31,6 +32,7 @@ export default () => {
endpoint,
initialIncomingEmail: incomingEmail,
initialIsEnabled: parseBoolean(enabled),
+ isIssueTrackerEnabled: parseBoolean(issueTrackerEnabled),
outgoingName,
projectKey,
selectedTemplate,
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index cdbe39fd5e0..a11201627a4 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -95,11 +95,7 @@ export default class ProtectedBranchCreate {
}
hasProtectedBranchSuccessAlert() {
- return (
- window.gon?.features?.branchRules &&
- this.isLocalStorageAvailable &&
- localStorage.getItem(IS_PROTECTED_BRANCH_CREATED)
- );
+ return this.isLocalStorageAvailable && localStorage.getItem(IS_PROTECTED_BRANCH_CREATED);
}
createSuccessAlert() {
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 7ecc39a56e7..b3033ddf3b6 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -218,7 +218,7 @@ export default {
type="submit"
size="small"
class="gl-mr-2"
- data-qa-selector="add_issue_button"
+ data-testid="add_issue_button"
>
{{ __('Add') }}
</gl-button>
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 1846b9cf8f4..f92c81a7eb2 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -217,7 +217,7 @@ export default {
:aria-label="inputPlaceholder"
type="text"
class="gl-w-full gl-border-none gl-outline-0"
- data-qa-selector="add_issue_field"
+ data-testid="add_issue_field"
autocomplete="off"
@input="onInput"
@focus="onFocus"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index 24b350c7f18..f672acda062 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -220,7 +220,6 @@ export default {
<gl-button
v-if="canAdmin"
size="small"
- data-qa-selector="related_issues_plus_button"
data-testid="related-issues-plus-button"
:aria-label="addIssuableButtonText"
class="gl-ml-3"
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 63452f3eace..8d26917f749 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -104,7 +104,7 @@ export default {
{{ heading }}
</h4>
<div class="related-issues-token-body" :class="{ 'sortable-container': canReorder }">
- <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
+ <div v-if="isFetching" class="gl-mb-2" data-testid="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
size="sm"
@@ -146,7 +146,7 @@ export default {
:locked-message="issue.lockedMessage"
:work-item-type="issue.type"
event-namespace="relatedIssue"
- data-qa-selector="related_issuable_content"
+ data-testid="related_issuable_content"
class="gl-mx-n2"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
/>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js
new file mode 100644
index 00000000000..fd4d111b4b0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/constants.js
@@ -0,0 +1,24 @@
+import iconUrl from 'leaflet/dist/images/marker-icon.png';
+import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
+import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
+import { __ } from '~/locale';
+
+export const RENDER_ERROR_MSG = __(
+ 'The map can not be displayed because there was an error loading the GeoJSON file.',
+);
+
+export const OPEN_STREET_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+export const ICON_CONFIG = { iconUrl, iconRetinaUrl, shadowUrl };
+export const MAP_ATTRIBUTION = __('Map data from');
+export const OPEN_STREET_COPYRIGHT_LINK =
+ '<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>';
+
+export const POPUP_CONTENT_TEMPLATE = `
+<div class="gl-pt-4">
+ <% eachFunction(popupProperties, function(value, label) { %>
+ <div>
+ <strong><%- label %>:</strong> <span><%- value %></span>
+ </div>
+ <% }); %>
+</div>
+`;
diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue
new file mode 100644
index 00000000000..1c9fccc2c19
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/geo_json_viewer.vue
@@ -0,0 +1,32 @@
+<script>
+import { createAlert } from '~/alert';
+import { RENDER_ERROR_MSG } from './constants';
+import { initLeafletMap } from './utils';
+
+export default {
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasError: false,
+ loading: true,
+ };
+ },
+ mounted() {
+ try {
+ initLeafletMap(this.$refs.map, JSON.parse(this.blob.rawTextBlob));
+ } catch (error) {
+ createAlert({ message: RENDER_ERROR_MSG });
+ this.hasError = true;
+ }
+ },
+};
+</script>
+
+<template>
+ <div v-if="!hasError" ref="map" class="gl-h-100vh gl-z-index-0" data-testid="map"></div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js b/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js
new file mode 100644
index 00000000000..615f7fd2b47
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/geo_json/utils.js
@@ -0,0 +1,47 @@
+import { map, tileLayer, geoJson, featureGroup, Icon } from 'leaflet';
+import { template, each } from 'lodash';
+import {
+ OPEN_STREET_TILE_URL,
+ MAP_ATTRIBUTION,
+ OPEN_STREET_COPYRIGHT_LINK,
+ ICON_CONFIG,
+ POPUP_CONTENT_TEMPLATE,
+} from './constants';
+
+const generateOpenStreetMapTiles = () => {
+ const attribution = `${MAP_ATTRIBUTION} ${OPEN_STREET_COPYRIGHT_LINK}`;
+ return tileLayer(OPEN_STREET_TILE_URL, { attribution });
+};
+
+export const popupContent = (popupProperties) => {
+ return template(POPUP_CONTENT_TEMPLATE)({
+ eachFunction: each,
+ popupProperties,
+ });
+};
+
+const loadGeoJsonGroupAndBounds = (geoJsonData) => {
+ const layers = [];
+ const geoJsonGroup = geoJson(geoJsonData, {
+ onEachFeature: (feature, layer) => {
+ layers.push(layer);
+ if (feature.properties) {
+ layer.bindPopup(popupContent(feature.properties));
+ }
+ },
+ });
+
+ return { geoJsonGroup, bounds: featureGroup(layers).getBounds() };
+};
+
+export const initLeafletMap = (el, geoJsonData) => {
+ if (!el || !geoJsonData) return;
+
+ import('leaflet/dist/leaflet.css');
+ Icon.Default.mergeOptions(ICON_CONFIG);
+ const leafletMap = map(el, { layers: [generateOpenStreetMapTiles()] });
+ const { bounds, geoJsonGroup } = loadGeoJsonGroupAndBounds(geoJsonData);
+
+ geoJsonGroup.addTo(leafletMap);
+ leafletMap.fitBounds(bounds);
+};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 68b2cf6f3da..b749702972f 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -4,7 +4,7 @@ const viewers = {
image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'),
empty: () => import('./empty_viewer.vue'),
- text: () => import('~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'),
+ text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
audio: () => import('./audio_viewer.vue'),
@@ -12,6 +12,7 @@ const viewers = {
sketch: () => import('./sketch_viewer.vue'),
notebook: () => import('./notebook_viewer.vue'),
openapi: () => import('./openapi_viewer.vue'),
+ geo_json: () => import('./geo_json/geo_json_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 1da445a7906..42108e8dfba 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -3,7 +3,6 @@ import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton }
import { s__, sprintf, n__ } from '~/locale';
import { createAlert, VARIANT_INFO } from '~/alert';
import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import {
POLLING_INTERVAL_DEFAULT,
@@ -43,7 +42,6 @@ export default {
ConflictsModal,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
apollo: {
project: {
query: forkDetailsQuery,
@@ -198,7 +196,6 @@ export default {
},
hasUpdateButton() {
return (
- this.glFeatures.synchronizeFork &&
this.canSyncBranch &&
((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
);
@@ -314,7 +311,7 @@ export default {
>
{{ $options.i18n.inaccessibleProject }}
</div>
- <div class="gl-display-flex gl-xs-display-none!">
+ <div class="gl-display-none gl-sm-display-flex">
<gl-button
v-if="hasCreateMrButton"
class="gl-ml-4"
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index 1e4b1e36514..f5684cebbf9 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -15,7 +15,7 @@ export const initSearchApp = () => {
const store = createStore({
query,
navigation,
- useNewNavigation: gon.use_new_navigation,
+ useSidebarNavigation: gon.use_new_navigation,
});
initTopbar(store);
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 317145d4cd1..cd289be4c05 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,23 +1,24 @@
<script>
import { mapState, mapGetters } from 'vuex';
-import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
-import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue';
+import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
+import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue';
import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
-import ResultsFilters from './results_filters.vue';
+import IssuesFilters from './issues_filters.vue';
import LanguageFilter from './language_filter/index.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
- ResultsFilters,
- ScopeNavigation,
- ScopeNewNavigation,
+ IssuesFilters,
+ ScopeLegacyNavigation,
+ ScopeSidebarNavigation,
LanguageFilter,
SidebarPortal,
},
computed: {
- ...mapState(['urlQuery', 'useNewNavigation']),
+ // useSidebarNavigation refers to whether the new left sidebar navigation is enabled
+ ...mapState(['useSidebarNavigation']),
...mapGetters(['currentScope']),
showIssueAndMergeFilters() {
return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
@@ -25,7 +26,12 @@ export default {
showBlobFilter() {
return this.currentScope === SCOPE_BLOB;
},
- showOldNavigation() {
+ showLabelFilter() {
+ return this.currentScope === SCOPE_ISSUES;
+ },
+ showScopeNavigation() {
+ // showScopeNavigation refers to whether the scope navigation should be shown
+ // while the legacy navigation is being used and there are no search results the scope navigation has to be hidden
return Boolean(this.currentScope);
},
},
@@ -33,19 +39,19 @@ export default {
</script>
<template>
- <section v-if="useNewNavigation">
+ <section v-if="useSidebarNavigation">
<sidebar-portal>
- <scope-new-navigation />
- <results-filters v-if="showIssueAndMergeFilters" />
+ <scope-sidebar-navigation />
+ <issues-filters v-if="showIssueAndMergeFilters" />
<language-filter v-if="showBlobFilter" />
</sidebar-portal>
</section>
<section
- v-else
+ v-else-if="showScopeNavigation"
class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5"
>
- <scope-navigation />
- <results-filters v-if="showIssueAndMergeFilters" />
+ <scope-legacy-navigation />
+ <issues-filters v-if="showIssueAndMergeFilters" />
<language-filter v-if="showBlobFilter" />
</section>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index 56e44d454a1..2a7988cd4c6 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -19,7 +19,7 @@ export default {
<template>
<div>
- <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" />
+ <radio-filter :filter-data="$options.confidentialFilterData" />
<hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue
new file mode 100644
index 00000000000..8928f80d83a
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_CLICK,
+ TRACKING_LABEL_APPLY,
+ TRACKING_CATEGORY,
+ TRACKING_LABEL_RESET,
+} from '../constants/index';
+import { confidentialFilterData } from '../constants/confidential_filter_data';
+import { stateFilterData } from '../constants/state_filter_data';
+import ConfidentialityFilter from './confidentiality_filter.vue';
+import { labelFilterData } from './label_filter/data';
+import LabelFilter from './label_filter/index.vue';
+import StatusFilter from './status_filter.vue';
+
+export default {
+ name: 'IssuesFilters',
+ components: {
+ GlButton,
+ GlLink,
+ StatusFilter,
+ ConfidentialityFilter,
+ LabelFilter,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
+ ...mapGetters(['currentScope']),
+ showReset() {
+ return this.urlQuery.state || this.urlQuery.confidential || this.urlQuery.labels;
+ },
+ showConfidentialityFilter() {
+ return Object.values(confidentialFilterData.scopes).includes(this.currentScope);
+ },
+ showStatusFilter() {
+ return Object.values(stateFilterData.scopes).includes(this.currentScope);
+ },
+ showLabelFilter() {
+ return (
+ Object.values(labelFilterData.scopes).includes(this.currentScope) &&
+ this.glFeatures.searchIssueLabelAggregation
+ );
+ },
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
+ },
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'resetQuery']),
+ applyQueryWithTracking() {
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+ this.applyQuery();
+ },
+ resetQueryWithTracking() {
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: TRACKING_CATEGORY,
+ });
+ this.resetQuery();
+ },
+ },
+};
+</script>
+
+<template>
+ <form class="issue-filters gl-px-5 gl-pt-0" @submit.prevent="applyQueryWithTracking">
+ <hr v-if="!useNewNavigation" :class="hrClasses" />
+ <status-filter v-if="showStatusFilter" class="gl-mb-5" />
+ <confidentiality-filter v-if="showConfidentialityFilter" class="gl-mb-5" />
+ <label-filter v-if="showLabelFilter" />
+ <div class="gl-display-flex gl-align-items-center gl-mt-4">
+ <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
+ {{ __('Apply') }}
+ </gl-button>
+ <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQueryWithTracking">{{
+ __('Reset filters')
+ }}</gl-link>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/data.js b/app/assets/javascripts/search/sidebar/components/label_filter/data.js
new file mode 100644
index 00000000000..654357da902
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/data.js
@@ -0,0 +1,23 @@
+import { __ } from '~/locale';
+
+export const FIRST_DROPDOWN_INDEX = 0;
+
+export const SEARCH_BOX_INDEX = 0;
+
+export const SEARCH_INPUT_DESCRIPTION = 'label-search-input-description';
+
+export const SEARCH_RESULTS_DESCRIPTION = 'label-search-results-description';
+
+const header = __('Labels');
+
+const scopes = {
+ ISSUES: 'issues',
+};
+
+const filterParam = 'labels';
+
+export const labelFilterData = {
+ header,
+ scopes,
+ filterParam,
+};
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
new file mode 100644
index 00000000000..74855482b5d
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue
@@ -0,0 +1,291 @@
+<script>
+import {
+ GlSearchBoxByType,
+ GlLabel,
+ GlLoadingIcon,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlFormCheckboxGroup,
+ GlDropdownForm,
+ GlAlert,
+ GlOutsideDirective as Outside,
+} from '@gitlab/ui';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { uniq } from 'lodash';
+import { rgbFromHex } from '@gitlab/ui/dist/utils/utils';
+import { slugify } from '~/lib/utils/text_utility';
+import { s__, sprintf } from '~/locale';
+
+import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+
+import {
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+} from '~/vue_shared/global_search/constants';
+
+import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
+import LabelDropdownItems from './label_dropdown_items.vue';
+
+import {
+ FIRST_DROPDOWN_INDEX,
+ SEARCH_BOX_INDEX,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_INPUT_DESCRIPTION,
+ labelFilterData,
+} from './data';
+
+import { trackSelectCheckbox, trackOpenDropdown } from './tracking';
+
+export default {
+ name: 'LabelFilter',
+ directives: { Outside },
+ components: {
+ DropdownKeyboardNavigation,
+ GlSearchBoxByType,
+ LabelDropdownItems,
+ GlLabel,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlFormCheckboxGroup,
+ GlDropdownForm,
+ GlLoadingIcon,
+ GlAlert,
+ },
+ data() {
+ return {
+ currentFocusIndex: SEARCH_BOX_INDEX,
+ isFocused: false,
+ };
+ },
+ i18n: {
+ SEARCH_LABELS: s__('GlobalSearch|Search labels'),
+ DROPDOWN_HEADER: s__('GlobalSearch|Label(s)'),
+ AGGREGATIONS_ERROR_MESSAGE: s__('GlobalSearch|Fetching aggregations error.'),
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ },
+ computed: {
+ ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']),
+ ...mapGetters([
+ 'filteredLabels',
+ 'filteredUnselectedLabels',
+ 'filteredAppliedSelectedLabels',
+ 'appliedSelectedLabels',
+ 'filteredUnappliedSelectedLabels',
+ ]),
+ searchInputDescribeBy() {
+ if (this.isLoggedIn) {
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
+ }
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
+ },
+ dropdownResultsDescription() {
+ if (!this.showSearchDropdown) {
+ return ''; // This allows aria-live to see register an update when the dropdown is shown
+ }
+
+ if (this.showDefaultItems) {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
+ count: this.filteredLabels.length,
+ });
+ }
+
+ return this.loading
+ ? this.$options.i18n.SEARCH_RESULTS_LOADING
+ : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
+ count: this.filteredLabels.length,
+ });
+ },
+ currentFocusedOption() {
+ return this.filteredLabels[this.currentFocusIndex] || null;
+ },
+ currentFocusedId() {
+ return `${slugify(this.currentFocusedOption?.parent_full_name || 'undefined-name')}_${slugify(
+ this.currentFocusedOption?.title || 'undefined-title',
+ )}`;
+ },
+ defaultIndex() {
+ if (this.showDefaultItems) {
+ return SEARCH_BOX_INDEX;
+ }
+ return FIRST_DROPDOWN_INDEX;
+ },
+ hasSelectedLabels() {
+ return this.filteredAppliedSelectedLabels.length > 0;
+ },
+ hasUnselectedLabels() {
+ return this.filteredUnselectedLabels.length > 0;
+ },
+ dividerClasses() {
+ return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
+ },
+ labelSearchBox() {
+ return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]');
+ },
+ combinedSelectedFilters() {
+ const appliedSelectedLabelKeys = this.appliedSelectedLabels.map((label) => label.key);
+ const { labels = [] } = this.query;
+
+ return uniq([...appliedSelectedLabelKeys, ...labels]);
+ },
+ searchLabels: {
+ get() {
+ return this.searchLabelString;
+ },
+ set(value) {
+ this.setLabelFilterSearch({ value });
+ },
+ },
+ selectedFilters: {
+ get() {
+ return this.combinedSelectedFilters;
+ },
+ set(value) {
+ this.setQuery({ key: this.$options.labelFilterData?.filterParam, value });
+
+ trackSelectCheckbox(value);
+ },
+ },
+ },
+ async created() {
+ await this.fetchAllAggregation();
+ },
+ methods: {
+ ...mapActions(['fetchAllAggregation', 'setQuery', 'closeLabel', 'setLabelFilterSearch']),
+ openDropdown() {
+ this.isFocused = true;
+
+ trackOpenDropdown();
+ },
+ closeDropdown(event) {
+ const { target } = event;
+
+ if (this.labelSearchBox !== target) {
+ this.isFocused = false;
+ }
+ },
+ onLabelClose(event) {
+ if (!event?.target?.closest('.gl-label')?.dataset) {
+ return;
+ }
+
+ const { key } = event.target.closest('.gl-label').dataset;
+ this.closeLabel({ key });
+ },
+ reactiveLabelColor(label) {
+ const { color, key } = label;
+
+ return this.query?.labels?.some((labelKey) => labelKey === key)
+ ? color
+ : `rgba(${rgbFromHex(color)}, 0.3)`;
+ },
+ isLabelClosable(label) {
+ const { key } = label;
+ return this.query?.labels?.some((labelKey) => labelKey === key);
+ },
+ },
+ FIRST_DROPDOWN_INDEX,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_INPUT_DESCRIPTION,
+ labelFilterData,
+};
+</script>
+
+<template>
+ <div class="gl-pb-0 gl-md-pt-0 label-filter gl-relative">
+ <h5
+ class="gl-my-0"
+ data-testid="label-filter-title"
+ :class="{ 'gl-font-sm': useSidebarNavigation }"
+ >
+ {{ $options.labelFilterData.header }}
+ </h5>
+ <div class="gl-my-5">
+ <gl-label
+ v-for="label in appliedSelectedLabels"
+ :key="label.key"
+ class="gl-mr-2 gl-mb-2 gl-bg-gray-10"
+ :data-key="label.key"
+ :background-color="reactiveLabelColor(label)"
+ :title="label.title"
+ :show-close-button="isLabelClosable(label)"
+ @close="onLabelClose"
+ />
+ </div>
+ <gl-search-box-by-type
+ ref="searchLabelInputBox"
+ v-model="searchLabels"
+ role="searchbox"
+ autocomplete="off"
+ :placeholder="$options.i18n.SEARCH_LABELS"
+ :aria-activedescendant="currentFocusedId"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
+ @focusin="openDropdown"
+ @keydown.esc="closeDropdown"
+ />
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
+ searchInputDescribeBy
+ }}</span>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ dropdownResultsDescription }}
+ </span>
+ <div
+ v-if="isFocused"
+ v-outside="closeDropdown"
+ data-testid="header-search-dropdown-menu"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3 gl-z-index-1"
+ :class="{
+ 'gl-max-w-none!': useSidebarNavigation,
+ 'gl-min-w-full!': useSidebarNavigation,
+ 'gl-w-full!': useSidebarNavigation,
+ }"
+ >
+ <div class="header-search-dropdown-content gl-py-2">
+ <dropdown-keyboard-navigation
+ v-model="currentFocusIndex"
+ :max="filteredLabels.length - 1"
+ :min="$options.FIRST_DROPDOWN_INDEX"
+ :default-index="defaultIndex"
+ :enable-cycle="true"
+ />
+ <div v-if="!aggregations.error">
+ <gl-dropdown-section-header v-if="hasSelectedLabels || hasUnselectedLabels">{{
+ $options.i18n.DROPDOWN_HEADER
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-form>
+ <gl-form-checkbox-group v-model="selectedFilters">
+ <label-dropdown-items
+ v-if="hasSelectedLabels"
+ data-testid="selected-lavel-items"
+ :labels="filteredAppliedSelectedLabels"
+ />
+ <gl-dropdown-divider v-if="hasSelectedLabels && hasUnselectedLabels" />
+ <label-dropdown-items
+ v-if="hasUnselectedLabels"
+ data-testid="unselected-lavel-items"
+ :labels="filteredUnselectedLabels"
+ />
+ </gl-form-checkbox-group>
+ </gl-dropdown-form>
+ </div>
+ <gl-alert v-else :dismissible="false" variant="danger">
+ {{ $options.i18n.AGGREGATIONS_ERROR_MESSAGE }}
+ </gl-alert>
+ <gl-loading-icon v-if="aggregations.fetching" size="lg" class="my-4" />
+ </div>
+ </div>
+ <hr v-if="!useSidebarNavigation" :class="dividerClasses" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue
new file mode 100644
index 00000000000..7a9e6a2e4fc
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/label_dropdown_items.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ name: 'LabelDropdownItems',
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ labels: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <ul class="gl-list-style-none gl-px-0">
+ <li
+ v-for="label in labels"
+ :id="label.key"
+ :ref="label.key"
+ :key="label.key"
+ :aria-label="label.title"
+ tabindex="-1"
+ class="gl-px-5 gl-py-3 label-filter-menu-item"
+ >
+ <gl-form-checkbox
+ class="label-with-color-checkbox gl-display-inline-flex gl-h-5 gl-min-h-5"
+ :value="label.key"
+ >
+ <span
+ data-testid="label-color-indicator"
+ class="gl-rounded-base gl-w-5 gl-h-5 gl-display-inline-block gl-vertical-align-bottom gl-mr-3"
+ :style="{ 'background-color': label.color }"
+ ></span>
+ <span class="gl-reset-text-align gl-m-0 gl-p-0 label-title">{{
+ label.title
+ }}</span></gl-form-checkbox
+ >
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js
new file mode 100644
index 00000000000..c38922a559c
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/label_filter/tracking.js
@@ -0,0 +1,21 @@
+import Tracking from '~/tracking';
+
+export const TRACKING_CATEGORY = 'Language filters';
+export const TRACKING_LABEL_FILTER = 'Label Key';
+
+export const TRACKING_LABEL_DROPDOWN = 'Dropdown';
+export const TRACKING_LABEL_CHECKBOX = 'Label Checkbox';
+
+export const TRACKING_ACTION_SELECT = 'search:agreggations:label:select';
+export const TRACKING_ACTION_SHOW = 'search:agreggations:label:show';
+
+export const trackSelectCheckbox = (value) =>
+ Tracking.event(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, {
+ label: TRACKING_LABEL_FILTER,
+ property: value,
+ });
+
+export const trackOpenDropdown = () =>
+ Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, {
+ label: TRACKING_LABEL_DROPDOWN,
+ });
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue
new file mode 100644
index 00000000000..b820ca837bc
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/checkbox_filter.vue
@@ -0,0 +1,91 @@
+<script>
+import Vue from 'vue';
+import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { intersection } from 'lodash';
+import Tracking from '~/tracking';
+import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../../constants';
+import { formatSearchResultCount } from '../../../store/utils';
+
+export const TRACKING_LABEL_SET = 'set';
+export const TRACKING_LABEL_CHECKBOX = 'checkbox';
+
+export default {
+ name: 'CheckboxFilter',
+ components: {
+ GlFormCheckboxGroup,
+ GlFormCheckbox,
+ },
+ props: {
+ filtersData: {
+ type: Object,
+ required: true,
+ },
+ trackingNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['query', 'useNewNavigation']),
+ ...mapGetters(['queryLanguageFilters']),
+ dataFilters() {
+ return Object.values(this.filtersData?.filters || []);
+ },
+ flatDataFilterValues() {
+ return this.dataFilters.map(({ value }) => value);
+ },
+ selectedFilter: {
+ get() {
+ return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
+ },
+ async set(value) {
+ this.setQuery({ key: this.filtersData?.filterParam, value });
+
+ await Vue.nextTick();
+ this.trackSelectCheckbox();
+ },
+ },
+ labelCountClasses() {
+ return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500'];
+ },
+ },
+ methods: {
+ ...mapActions(['setQuery']),
+ getFormattedCount(count) {
+ return formatSearchResultCount(count);
+ },
+ trackSelectCheckbox() {
+ Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, {
+ label: TRACKING_LABEL_SET,
+ property: this.selectedFilter,
+ });
+ },
+ },
+ NAV_LINK_COUNT_DEFAULT_CLASSES,
+ LABEL_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <gl-form-checkbox-group v-model="selectedFilter">
+ <gl-form-checkbox
+ v-for="f in dataFilters"
+ :key="f.label"
+ :value="f.label"
+ class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
+ :class="$options.LABEL_DEFAULT_CLASSES"
+ >
+ <span
+ class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
+ >
+ <span data-testid="label">
+ {{ f.label }}
+ </span>
+ <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
+ {{ getFormattedCount(f.count) }}
+ </span>
+ </span>
+ </gl-form-checkbox>
+ </gl-form-checkbox-group>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
index 40b50f657f0..c10b14bd116 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
@@ -2,10 +2,9 @@
import { GlButton, GlAlert, GlForm } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__, sprintf } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
import { convertFiltersData } from '../../utils';
-import CheckboxFilter from '../checkbox_filter.vue';
+import CheckboxFilter from './checkbox_filter.vue';
import {
trackShowMore,
trackShowHasOverMax,
@@ -14,7 +13,7 @@ import {
TRACKING_ACTION_SELECT,
} from './tracking';
-import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data';
+import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH, languageFilterData } from './data';
export default {
name: 'LanguageFilter',
@@ -24,7 +23,6 @@ export default {
GlAlert,
GlForm,
},
- mixins: [glFeatureFlagsMixin()],
data() {
return {
showAll: false,
@@ -65,22 +63,25 @@ export default {
hasOverMax() {
return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH;
},
- dividerClasses() {
+ dividerClassesTop() {
return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
},
+ dividerClassesBottom() {
+ return [...HR_DEFAULT_CLASSES, 'gl-mt-5'];
+ },
hasQueryFilters() {
return this.queryLanguageFilters.length > 0;
},
},
async created() {
- await this.fetchLanguageAggregation();
+ await this.fetchAllAggregation();
},
methods: {
...mapActions([
'applyQuery',
'resetLanguageQuery',
'resetLanguageQueryWithRedirect',
- 'fetchLanguageAggregation',
+ 'fetchAllAggregation',
]),
onShowMore() {
this.showAll = true;
@@ -108,69 +109,73 @@ export default {
},
HR_DEFAULT_CLASSES,
TRACKING_ACTION_SELECT,
+ languageFilterData,
};
</script>
<template>
- <gl-form
- v-if="hasBuckets"
- class="gl-pt-5 gl-md-pt-0 language-filter-checkbox"
- @submit.prevent="submitQuery"
- >
- <hr v-if="!useNewNavigation" :class="dividerClasses" />
- <div
- v-if="!aggregations.error"
- class="gl-overflow-x-hidden gl-overflow-y-auto"
- :class="{ 'language-filter-max-height': showAll }"
+ <div>
+ <gl-form
+ v-if="hasBuckets"
+ class="gl-m-5 gl-my-0 language-filter-checkbox"
+ @submit.prevent="submitQuery"
>
- <checkbox-filter
- :filters-data="filtersData"
- :tracking-namespace="$options.TRACKING_ACTION_SELECT"
- />
- <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
- $options.i18n.showingMax
- }}</span>
- </div>
- <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{
- $options.i18n.loadError
- }}</gl-alert>
- <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all">
- <gl-button
- data-testid="show-more-button"
- category="tertiary"
- variant="link"
- size="small"
- button-text-classes="gl-font-sm"
- @click="onShowMore"
- >
- {{ $options.i18n.showMore }}
- </gl-button>
- </div>
- <div v-if="!aggregations.error">
- <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
+ <hr v-if="!useNewNavigation" :class="dividerClassesTop" />
+ <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }">
+ {{ $options.languageFilterData.header }}
+ </h5>
<div
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5"
+ v-if="!aggregations.error"
+ class="gl-overflow-x-hidden gl-overflow-y-auto"
+ :class="{ 'language-filter-max-height': showAll }"
>
+ <checkbox-filter
+ :filters-data="filtersData"
+ :tracking-namespace="$options.TRACKING_ACTION_SELECT"
+ />
+ <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
+ $options.i18n.showingMax
+ }}</span>
+ </div>
+ <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{
+ $options.i18n.loadError
+ }}</gl-alert>
+ <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all">
<gl-button
- category="primary"
- variant="confirm"
- type="submit"
- :disabled="!sidebarDirty"
- data-testid="apply-button"
- >
- {{ $options.i18n.apply }}
- </gl-button>
- <gl-button
+ data-testid="show-more-button"
category="tertiary"
variant="link"
size="small"
- :disabled="!hasQueryFilters && !sidebarDirty"
- data-testid="reset-button"
- @click="cleanResetFilters"
+ button-text-classes="gl-font-sm"
+ @click="onShowMore"
>
- {{ $options.i18n.reset }}
+ {{ $options.i18n.showMore }}
</gl-button>
</div>
- </div>
- </gl-form>
+ <div v-if="!aggregations.error">
+ <hr v-if="!useNewNavigation" :class="dividerClassesBottom" />
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ type="submit"
+ :disabled="!sidebarDirty"
+ data-testid="apply-button"
+ >
+ {{ $options.i18n.apply }}
+ </gl-button>
+ <gl-button
+ v-if="hasQueryFilters && sidebarDirty"
+ category="tertiary"
+ variant="link"
+ size="small"
+ data-testid="reset-button"
+ @click="cleanResetFilters"
+ >
+ {{ $options.i18n.reset }}
+ </gl-button>
+ </div>
+ </div>
+ </gl-form>
+ </div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index 477ba37dab7..10ece1b82eb 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -56,7 +56,9 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0 gl-mb-5" :class="{ 'gl-font-sm': useNewNavigation }">
+ {{ filterData.header }}
+ </h5>
<gl-form-radio-group v-model="selectedFilter">
<gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
{{ radioLabel(f) }}
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue
index fc41baee831..e682369d60b 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_legacy_navigation.vue
@@ -8,7 +8,7 @@ import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../con
import { slugifyWithUnderscore } from '../../../lib/utils/text_utility';
export default {
- name: 'ScopeNavigation',
+ name: 'ScopeLegacyNavigation',
i18n: {
countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'),
},
diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
index 86b7cc577a6..3707e152e47 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_sidebar_navigation.vue
@@ -6,7 +6,7 @@ import NavItem from '~/super_sidebar/components/nav_item.vue';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
export default {
- name: 'ScopeNewNavigation',
+ name: 'ScopeSidebarNavigation',
i18n: {
countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'),
},
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index 44d6b537b7b..2a3d9ede982 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -19,7 +19,7 @@ export default {
<template>
<div>
- <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" />
+ <radio-filter :filter-data="$options.stateFilterData" />
<hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
index 9519154a571..99d8821db61 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -12,7 +12,10 @@ export const NAV_LINK_DEFAULT_CLASSES = [
'gl-justify-content-space-between',
];
export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal'];
-export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100'];
+export const HR_DEFAULT_CLASSES = ['hr-x', 'gl-border-gray-100'];
export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
-export const TRACKING_LABEL_CHECKBOX = 'Checkbox';
+export const TRACKING_ACTION_CLICK = 'search:filters:click';
+export const TRACKING_LABEL_APPLY = 'Apply Filters';
+export const TRACKING_LABEL_RESET = 'Reset Filters';
+export const TRACKING_CATEGORY = 'Issue filters';
diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue
index 2bf144705c4..9f28d2bfc99 100644
--- a/app/assets/javascripts/search/sort/components/app.vue
+++ b/app/assets/javascripts/search/sort/components/app.vue
@@ -1,21 +1,14 @@
<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlTooltipDirective,
-} from '@gitlab/ui';
+import { GlCollapsibleListbox, GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { SORT_DIRECTION_UI } from '../constants';
export default {
name: 'GlobalSearchSort',
components: {
+ GlCollapsibleListbox,
GlButtonGroup,
GlButton,
- GlDropdown,
- GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -26,8 +19,19 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ selectedSortOptionTitle: '',
+ };
+ },
computed: {
...mapState(['query']),
+ listboxOptions() {
+ return this.searchSortOptions.map((option) => ({
+ text: option.title,
+ value: option.title,
+ }));
+ },
selectedSortOption: {
get() {
const { sort } = this.query;
@@ -60,14 +64,23 @@ export default {
return this.query?.sort?.includes('asc') ? SORT_DIRECTION_UI.asc : SORT_DIRECTION_UI.desc;
},
},
+ watch: {
+ selectedSortOption: {
+ handler() {
+ this.selectedSortOptionTitle = this.selectedSortOption.title;
+ },
+ immediate: true,
+ },
+ },
methods: {
...mapActions(['applyQuery', 'setQuery']),
- handleSortChange(option) {
- if (!option.sortable) {
- this.selectedSortOption = option.sortParam;
+ handleSortChange(value) {
+ const selectedOption = this.searchSortOptions.find((option) => option.title === value);
+ if (!selectedOption.sortable) {
+ this.selectedSortOption = selectedOption.sortParam;
} else {
// Default new sort options to desc
- this.selectedSortOption = option.sortParam.desc;
+ this.selectedSortOption = selectedOption.sortParam.desc;
}
},
handleSortDirectionChange() {
@@ -82,16 +95,15 @@ export default {
<template>
<gl-button-group>
- <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
- <gl-dropdown-item
- v-for="sortOption in searchSortOptions"
- :key="sortOption.title"
- is-check-item
- :is-checked="sortOption.title === selectedSortOption.title"
- @click="handleSortChange(sortOption)"
- >{{ sortOption.title }}</gl-dropdown-item
- >
- </gl-dropdown>
+ <gl-collapsible-listbox
+ v-model="selectedSortOptionTitle"
+ placement="right"
+ class="gl-z-index-1"
+ toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ :toggle-text="selectedSortOptionTitle"
+ :items="listboxOptions"
+ @select="handleSortChange"
+ />
<gl-button
v-gl-tooltip
:disabled="!selectedSortOption.sortable"
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 3d6ca2a6eee..077c46bbe22 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -5,6 +5,7 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
import {
@@ -108,10 +109,24 @@ export const applyQuery = ({ state }) => {
export const resetQuery = ({ state }) => {
visitUrl(
- setUrlParams({ ...state.query, page: null, state: null, confidential: null }, undefined, true),
+ setUrlParams(
+ { ...state.query, page: null, state: null, confidential: null, labels: null },
+ undefined,
+ true,
+ ),
);
};
+export const closeLabel = ({ state, commit }, { key }) => {
+ const labels = state?.query?.labels.filter((labelKey) => labelKey !== key);
+
+ setQuery({ state, commit }, { key: labelFilterData.filterParam, value: labels });
+};
+
+export const setLabelFilterSearch = ({ commit }, { value }) => {
+ commit(types.SET_LABEL_SEARCH_STRING, value);
+};
+
export const resetLanguageQueryWithRedirect = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true));
};
@@ -136,7 +151,7 @@ export const fetchSidebarCount = ({ commit, state }) => {
return Promise.all(promises);
};
-export const fetchLanguageAggregation = ({ commit, state }) => {
+export const fetchAllAggregation = ({ commit, state }) => {
commit(types.REQUEST_AGGREGATIONS);
return axios
.get(getAggregationsUrl())
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index c8ee0a3f9d9..91c16616f02 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,6 +1,7 @@
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -14,6 +15,7 @@ export const SIDEBAR_PARAMS = [
stateFilterData.filterParam,
confidentialFilterData.filterParam,
languageFilterData.filterParam,
+ labelFilterData.filterParam,
];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 135c9a3d67c..c7cb595f42f 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,5 +1,6 @@
import { findKey, has } from 'lodash';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants';
@@ -20,6 +21,43 @@ export const languageAggregationBuckets = (state) => {
);
};
+export const labelAggregationBuckets = (state) => {
+ return (
+ state?.aggregations?.data?.find(
+ (aggregation) => aggregation.name === labelFilterData.filterParam,
+ )?.buckets || []
+ );
+};
+
+export const filteredLabels = (state) => {
+ if (state.searchLabelString === '') {
+ return labelAggregationBuckets(state);
+ }
+ return labelAggregationBuckets(state).filter((label) => {
+ return label.title.toLowerCase().includes(state.searchLabelString.toLowerCase());
+ });
+};
+
+export const filteredAppliedSelectedLabels = (state) =>
+ filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key));
+
+export const appliedSelectedLabels = (state) => {
+ return labelAggregationBuckets(state)?.filter((label) =>
+ state?.urlQuery?.labels?.includes(label.key),
+ );
+};
+
+export const filteredUnappliedSelectedLabels = (state) =>
+ filteredLabels(state)?.filter((label) => state?.query?.labels?.includes(label.key));
+
+export const filteredUnselectedLabels = (state) => {
+ if (!state?.urlQuery?.labels) {
+ return filteredLabels(state);
+ }
+
+ return filteredLabels(state)?.filter((label) => !state?.urlQuery?.labels?.includes(label.key));
+};
+
export const currentScope = (state) => findKey(state.navigation, { active: true });
export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || [];
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index 634f8f7a7fa..2478518c157 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -7,11 +7,11 @@ import createState from './state';
Vue.use(Vuex);
-export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({
+export const getStoreConfig = (storeInitValues) => ({
actions,
getters,
mutations,
- state: createState({ query, navigation, useNewNavigation }),
+ state: createState(storeInitValues),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index 4ffbadcd083..021dd01ca93 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -15,3 +15,5 @@ export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT';
export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS';
export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS';
export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR';
+
+export const SET_LABEL_SEARCH_STRING = 'SET_LABEL_SEARCH_STRING';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index b2f9f5ab225..65bb21f1b8a 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -45,4 +45,7 @@ export default {
[types.RECEIVE_AGGREGATIONS_ERROR](state) {
state.aggregations = { fetching: false, error: true, data: [] };
},
+ [types.SET_LABEL_SEARCH_STRING](state, value) {
+ state.searchLabelString = value;
+ },
};
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index a62b6728819..5407b08fa83 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
-const createState = ({ query, navigation, useNewNavigation }) => ({
+const createState = ({ query, navigation, useSidebarNavigation }) => ({
urlQuery: cloneDeep(query),
query,
groups: [],
@@ -14,12 +14,13 @@ const createState = ({ query, navigation, useNewNavigation }) => ({
},
sidebarDirty: false,
navigation,
- useNewNavigation,
+ useSidebarNavigation,
aggregations: {
error: false,
fetching: false,
data: [],
},
+ searchLabelString: '',
});
export default createState;
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index d57b3fda342..e7d97989195 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -164,7 +164,7 @@ export default {
<gl-tabs
content-class="gl-pt-0"
- data-qa-selector="security_configuration_container"
+ data-testid="security-configuration-container"
sync-active-tab-with-query-params
lazy
>
@@ -196,12 +196,9 @@ export default {
{{ $options.i18n.description }}
</p>
<p v-if="canViewCiHistory">
- <gl-link
- data-testid="security-view-history-link"
- data-qa-selector="security_configuration_history_link"
- :href="gitlabCiHistoryPath"
- >{{ $options.i18n.configurationHistory }}</gl-link
- >
+ <gl-link data-testid="security-view-history-link" :href="gitlabCiHistoryPath">{{
+ $options.i18n.configurationHistory
+ }}</gl-link>
</p>
</template>
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
index 315f676e659..c01df3573c5 100644
--- a/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue
+++ b/app/assets/javascripts/security_configuration/components/auto_dev_ops_alert.vue
@@ -28,7 +28,7 @@ export default {
variant="info"
:primary-button-link="autoDevopsPath"
:primary-button-text="$options.i18n.primaryButtonText"
- data-qa-selector="autodevops_container"
+ data-testid="autodevops-container"
@dismiss="dismissMethod"
>
<gl-sprintf :message="$options.i18n.body">
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 1b86d7d0a2b..1c2be99b393 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -15,9 +15,9 @@ import {
REPORT_TYPE_API_FUZZING,
} from '~/vue_shared/security_reports/constants';
-import kontraLogo from 'images/vulnerability/kontra-logo.svg';
-import scwLogo from 'images/vulnerability/scw-logo.svg';
-import secureflagLogo from 'images/vulnerability/secureflag-logo.svg';
+import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw';
+import scwLogo from 'images/vulnerability/scw-logo.svg?raw';
+import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index d1b705fe2fc..a757657339b 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -122,7 +122,7 @@ export default {
v-if="isNotSastIACTemporaryHack"
:class="statusClasses"
data-testid="feature-status"
- :data-qa-selector="`${feature.type}_status`"
+ :data-qa-feature="`${feature.type}_${enabled}_status`"
>
<feature-card-badge
v-if="hasBadge"
@@ -164,7 +164,7 @@ export default {
:href="feature.configurationPath"
variant="confirm"
:category="configurationButton.category"
- :data-qa-selector="`${feature.type}_enable_button`"
+ :data-testid="`${feature.type}_enable_button`"
class="gl-mt-5"
>
{{ configurationButton.text }}
@@ -176,7 +176,7 @@ export default {
variant="confirm"
:category="manageViaMrButtonCategory"
class="gl-mt-5"
- :data-qa-selector="`${feature.type}_mr_button`"
+ :data-testid="`${feature.type}_mr_button`"
@error="onError"
/>
diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
index 6dae8e50908..578d7c8a18c 100644
--- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue
+++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue
@@ -247,6 +247,8 @@ export default {
:label="__('Training mode')"
label-position="hidden"
:disabled="!securityTrainingEnabled"
+ data-qa-selector="security_training_toggle"
+ :data-qa-training-provider="provider.name"
@change="toggleProvider(provider)"
/>
<div v-if="$options.TEMP_PROVIDER_LOGOS[provider.name]" class="gl-ml-4">
diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js
index ea835945aa9..cf6a79fe939 100644
--- a/app/assets/javascripts/sentry/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -13,7 +13,7 @@ const index = function index() {
process.env.NODE_ENV === 'production'
? [gon.gitlab_url]
: [gon.gitlab_url, 'webpack-internal://'],
- release: gon.revision,
+ release: gon?.version,
tags: {
revision: gon?.revision,
feature_category: gon?.feature_category,
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index c61c02c8b3a..ea9702258b7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -24,6 +24,11 @@ export default {
required: false,
default: TYPE_ISSUE,
},
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isBusy() {
@@ -53,6 +58,7 @@ export default {
name="warning-solid"
aria-hidden="true"
class="merge-icon"
+ :class="{ 'gl-left-6!': selected }"
:size="12"
/>
<gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 06876546fa4..1d9233db361 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,9 +1,14 @@
<script>
-import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlDisclosureDropdownItem,
+ GlTooltipDirective,
+ GlOutsideDirective as Outside,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { createAlert } from '~/alert';
import toast from '~/vue_shared/plugins/global_toast';
@@ -15,17 +20,17 @@ export default {
icon: 'lock',
class: 'value',
iconClass: 'is-active',
- displayText: __('Locked'),
},
unlocked: {
class: ['no-value hide-collapsed'],
icon: 'lock-open',
iconClass: '',
- displayText: __('Unlocked'),
},
components: {
EditForm,
GlIcon,
+ GlLoadingIcon,
+ GlDisclosureDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -39,8 +44,23 @@ export default {
type: Boolean,
},
},
+ i18n: {
+ issue: __('issue'),
+ issueCapitalized: __('Issue'),
+ mergeRequest: __('merge request'),
+ mergeRequestCapitalized: __('Merge request'),
+ locked: __('Locked'),
+ unlocked: __('Unlocked'),
+ lockingMergeRequest: __('Locking %{issuableDisplayName}'),
+ unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'),
+ lockMergeRequest: __('Lock %{issuableDisplayName}'),
+ unlockMergeRequest: __('Unlock %{issuableDisplayName}'),
+ lockedMessage: __('%{issuableDisplayName} locked.'),
+ unlockedMessage: __('%{issuableDisplayName} unlocked.'),
+ },
data() {
return {
+ isLoading: false,
isLockDialogOpen: false,
};
},
@@ -49,18 +69,61 @@ export default {
isMovedMrSidebar() {
return this.glFeatures.movedMrSidebar;
},
+ isIssuable() {
+ return this.getNoteableData.targetType === TYPE_ISSUE;
+ },
issuableDisplayName() {
- const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
- return isInIssuePage ? __('issue') : __('merge request');
+ return this.isIssuable ? this.$options.i18n.issue : this.$options.i18n.mergeRequest;
+ },
+ issuableDisplayNameCapitalized() {
+ return this.isIssuable
+ ? this.$options.i18n.issueCapitalized
+ : this.$options.i18n.mergeRequestCapitalized;
},
isLocked() {
return this.getNoteableData.discussion_locked;
},
lockStatus() {
- return this.isLocked ? this.$options.locked : this.$options.unlocked;
+ return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked;
},
tooltipLabel() {
- return this.isLocked ? __('Locked') : __('Unlocked');
+ return this.isLocked ? this.$options.i18n.locked : this.$options.i18n.unlocked;
+ },
+ lockToggleInProgressText() {
+ return this.isLocked ? this.unlockingMergeRequestText : this.lockingMergeRequestText;
+ },
+ lockToggleText() {
+ return this.isLocked ? this.unlockMergeRequestText : this.lockMergeRequestText;
+ },
+ lockingMergeRequestText() {
+ return sprintf(this.$options.i18n.lockingMergeRequest, {
+ issuableDisplayName: this.issuableDisplayName,
+ });
+ },
+ unlockingMergeRequestText() {
+ return sprintf(this.$options.i18n.unlockingMergeRequest, {
+ issuableDisplayName: this.issuableDisplayName,
+ });
+ },
+ lockMergeRequestText() {
+ return sprintf(this.$options.i18n.lockMergeRequest, {
+ issuableDisplayName: this.issuableDisplayName,
+ });
+ },
+ unlockMergeRequestText() {
+ return sprintf(this.$options.i18n.unlockMergeRequest, {
+ issuableDisplayName: this.issuableDisplayName,
+ });
+ },
+ lockedMessageText() {
+ return sprintf(this.$options.i18n.lockedMessage, {
+ issuableDisplayName: this.issuableDisplayNameCapitalized,
+ });
+ },
+ unlockedMessageText() {
+ return sprintf(this.$options.i18n.unlockedMessage, {
+ issuableDisplayName: this.issuableDisplayNameCapitalized,
+ });
},
},
@@ -88,12 +151,7 @@ export default {
})
.then(() => {
if (this.isMovedMrSidebar) {
- toast(
- sprintf(__('%{issuableDisplayName} %{lockStatus}.'), {
- issuableDisplayName: capitalizeFirstCharacter(this.issuableDisplayName),
- lockStatus: this.isLocked ? __('locked') : __('unlocked'),
- }),
- );
+ toast(this.isLocked ? this.lockedMessageText : this.unlockedMessageText);
}
})
.catch(() => {
@@ -116,18 +174,35 @@ export default {
</script>
<template>
- <li v-if="isMovedMrSidebar" class="gl-dropdown-item">
+ <li v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item">
<button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked">
<span class="gl-dropdown-item-text-wrapper">
- <template v-if="isLocked">
- {{ sprintf(__('Unlock %{issuableType}'), { issuableType: issuableDisplayName }) }}
+ <template v-if="isLoading">
+ <gl-loading-icon inline size="sm" /> {{ lockToggleInProgressText }}
</template>
<template v-else>
- {{ sprintf(__('Lock %{issuableType}'), { issuableType: issuableDisplayName }) }}
+ {{ lockToggleText }}
</template>
</span>
</button>
</li>
+ <gl-disclosure-dropdown-item v-else-if="isMovedMrSidebar">
+ <button
+ type="button"
+ class="gl-new-dropdown-item-content"
+ data-testid="issuable-lock"
+ @click="toggleLocked"
+ >
+ <span class="gl-new-dropdown-item-text-wrapper">
+ <template v-if="isLoading">
+ <gl-loading-icon inline size="sm" /> {{ lockToggleInProgressText }}
+ </template>
+ <template v-else>
+ {{ lockToggleText }}
+ </template>
+ </span>
+ </button>
+ </gl-disclosure-dropdown-item>
<div v-else class="block issuable-sidebar-item lock">
<div
v-gl-tooltip.left.viewport="{ title: tooltipLabel }"
@@ -139,7 +214,7 @@ export default {
</div>
<div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold">
- {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }}
+ {{ lockMergeRequestText }}
<a
v-if="isEditable"
class="float-right lock-edit btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary gl-mr-n2"
@@ -164,7 +239,7 @@ export default {
/>
<div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class">
- {{ lockStatus.displayText }}
+ {{ lockStatus }}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 1680e42e5e4..2653748861b 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -157,7 +157,6 @@ export default {
:data-track-action="tracking.event"
:data-track-label="tracking.label"
:data-track-property="tracking.property"
- data-qa-selector="edit_link"
@keyup.esc="toggle"
@click="toggle"
>
diff --git a/app/assets/javascripts/sidebar/components/status/status_dropdown.vue b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue
index 7763ec00091..69ec4214712 100644
--- a/app/assets/javascripts/sidebar/components/status/status_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue
@@ -1,39 +1,35 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
import { statusDropdownOptions } from '../../constants';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
data() {
return {
status: null,
+ selectedValue: undefined,
};
},
computed: {
dropdownText() {
- return this.status?.text ?? this.$options.i18n.defaultDropdownText;
- },
- selectedValue() {
- return this.status?.value;
+ const selected = this.$options.statusDropdownOptions.find(
+ (option) => option.value === this.selectedValue,
+ );
+ return selected?.text || this.$options.i18n.defaultDropdownText;
},
},
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;
- }
+ handleReset() {
+ this.selectedValue = undefined;
},
},
i18n: {
dropdownTitle: __('Change status'),
defaultDropdownText: __('Select status'),
+ resetText: __('Reset'),
},
statusDropdownOptions,
};
@@ -41,17 +37,14 @@ export default {
<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.statusDropdownOptions"
- :key="statusOption.value"
- :is-checked="selectedValue === statusOption.value"
- is-check-item
- :title="statusOption.text"
- @click="onDropdownItemClick(statusOption)"
- >
- {{ statusOption.text }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ v-model="selectedValue"
+ block
+ :header-text="$options.i18n.dropdownTitle"
+ :reset-button-label="$options.i18n.resetText"
+ :toggle-text="dropdownText"
+ :items="$options.statusDropdownOptions"
+ @reset="handleReset"
+ />
</div>
</template>
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 f2b960ed02c..d6e1847aecb 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,7 +1,14 @@
<script>
-import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlDisclosureDropdownItem,
+ GlDropdownForm,
+ GlIcon,
+ GlLoadingIcon,
+ GlToggle,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { createAlert } from '~/alert';
-import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -22,6 +29,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
+ GlDisclosureDropdownItem,
GlDropdownForm,
GlIcon,
GlLoadingIcon,
@@ -89,6 +97,9 @@ export default {
isMovedMrSidebar() {
return this.glFeatures.movedMrSidebar;
},
+ isIssuable() {
+ return this.issuableType === TYPE_ISSUE;
+ },
isLoading() {
return this.$apollo.queries?.subscribed?.loading || this.loading;
},
@@ -182,18 +193,32 @@ export default {
</script>
<template>
- <gl-dropdown-form v-if="isMovedMrSidebar" class="gl-dropdown-item">
+ <gl-dropdown-form v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item">
<div class="gl-px-5 gl-pb-2 gl-pt-1">
<gl-toggle
:value="subscribed"
- :label="__('Notifications')"
+ :label="$options.i18n.notifications"
class="merge-request-notification-toggle"
label-position="left"
- data-testid="notifications-toggle"
+ data-testid="notification-toggle"
@change="toggleSubscribed"
/>
</div>
</gl-dropdown-form>
+ <gl-disclosure-dropdown-item
+ v-else-if="isMovedMrSidebar"
+ data-testid="notification-toggle"
+ @action="toggleSubscribed"
+ >
+ <template #list-item>
+ <gl-toggle
+ :value="subscribed"
+ :label="__('Notifications')"
+ class="merge-request-notification-toggle"
+ label-position="left"
+ />
+ </template>
+ </gl-disclosure-dropdown-item>
<sidebar-editable-item
v-else
ref="editable"
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue
index 4c3ba76d12d..bacbe5d46a6 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
import { subscriptionsDropdownOptions } from '../../constants';
@@ -8,27 +8,27 @@ export default {
i18n: {
defaultDropdownText: __('Select subscription'),
headerText: __('Change subscription'),
+ resetText: __('Reset'),
},
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
data() {
return {
- subscription: undefined,
+ selectedValue: undefined,
};
},
computed: {
dropdownText() {
- return this.subscription?.text ?? this.$options.i18n.defaultDropdownText;
- },
- selectedValue() {
- return this.subscription?.value;
+ const selected = this.$options.subscriptionsDropdownOptions.find(
+ (option) => option.value === this.selectedValue,
+ );
+ return selected?.text || this.$options.i18n.defaultDropdownText;
},
},
methods: {
- handleClick(option) {
- this.subscription = option.value === this.subscription?.value ? undefined : option;
+ handleReset() {
+ this.selectedValue = undefined;
},
},
};
@@ -36,16 +36,14 @@ export default {
<template>
<div>
<input type="hidden" name="update[subscription_event]" :value="selectedValue" />
- <gl-dropdown class="gl-w-full" :header-text="$options.i18n.headerText" :text="dropdownText">
- <gl-dropdown-item
- v-for="subscriptionsOption in $options.subscriptionsDropdownOptions"
- :key="subscriptionsOption.value"
- is-check-item
- :is-checked="selectedValue === subscriptionsOption.value"
- @click="handleClick(subscriptionsOption)"
- >
- {{ subscriptionsOption.text }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ v-model="selectedValue"
+ block
+ :header-text="$options.i18n.headerText"
+ :reset-button-label="$options.i18n.resetText"
+ :toggle-text="dropdownText"
+ :items="$options.subscriptionsDropdownOptions"
+ @reset="handleReset"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 74843bcc006..67e76b575e0 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -2,8 +2,6 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
import { gqlClient } from '~/issues/list/graphql';
import {
@@ -805,8 +803,6 @@ const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
export function mountSidebar(mediator, store) {
- initInviteMembersModal();
- initInviteMembersTrigger();
mountSidebarTodoWidget();
if (isAssigneesWidgetShown) {
mountSidebarAssigneesWidget();
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index 853293e5eb6..074c5fda29b 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -7,7 +7,7 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants';
-import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
+import CloneDropdownButton from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue';
import { getSnippetMixin } from '../mixins/snippets';
import { markBlobPerformance } from '../utils/blob';
@@ -31,7 +31,14 @@ export default {
mixins: [getSnippetMixin],
computed: {
embeddable() {
- return this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING;
+ return (
+ this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING && !this.isInPrivateProject
+ );
+ },
+ isInPrivateProject() {
+ const projectVisibility = this.snippet?.project?.visibility;
+ const isLimitedVisibilityProject = projectVisibility !== VISIBILITY_LEVEL_PUBLIC_STRING;
+ return projectVisibility ? isLimitedVisibilityProject : false;
},
canBeCloned() {
return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo);
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
index 260ee496df0..59f7c8d8d97 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
@@ -2,7 +2,7 @@
import { GlButton, GlFormGroup } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, sprintf } from '~/locale';
-import { SNIPPET_MAX_BLOBS } from '../constants';
+import { SNIPPET_MAX_BLOBS, SNIPPET_LIMITATIONS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
import SnippetBlobEdit from './snippet_blob_edit.vue';
@@ -50,6 +50,11 @@ export default {
total: SNIPPET_MAX_BLOBS,
});
},
+ limitationText() {
+ return sprintf(SNIPPET_LIMITATIONS, {
+ total: SNIPPET_MAX_BLOBS,
+ });
+ },
canDelete() {
return this.count > 1;
},
@@ -159,5 +164,8 @@ export default {
@click="addBlob"
>{{ addLabel }}</gl-button
>
+ <p v-if="!canAdd" data-testid="limitations_text" class="gl-text-secondary">
+ {{ limitationText }}
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index 84a940ed1f8..2d2eede9137 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -1,4 +1,4 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import {
VISIBILITY_LEVEL_PRIVATE_STRING,
VISIBILITY_LEVEL_INTERNAL_STRING,
@@ -41,3 +41,5 @@ export const SNIPPET_LEVELS_RESTRICTED = __(
export const SNIPPET_LEVELS_DISABLED = __(
'Visibility settings have been disabled by the administrator.',
);
+
+export const SNIPPET_LIMITATIONS = s__('Snippets|Snippets are limited to %{total} files.');
diff --git a/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js
new file mode 100644
index 00000000000..fa5fe02878c
--- /dev/null
+++ b/app/assets/javascripts/streaming/handle_streamed_relative_timestamps.js
@@ -0,0 +1,80 @@
+import { localTimeAgo } from '~/lib/utils/datetime_utility';
+
+const STREAMING_ELEMENT_NAME = 'streaming-element';
+const TIME_AGO_CLASS_NAME = 'js-timeago';
+
+// Callback handler for intersections observed on timestamps.
+const handleTimestampsIntersecting = (entries, observer) => {
+ entries.forEach((entry) => {
+ const { isIntersecting, target: timestamp } = entry;
+ if (isIntersecting) {
+ localTimeAgo([timestamp]);
+ observer.unobserve(timestamp);
+ }
+ });
+};
+
+// Finds nodes containing the `js-timeago` class within a mutation list.
+const findTimeAgoNodes = (mutationList) => {
+ return mutationList.reduce((acc, mutation) => {
+ [...mutation.addedNodes].forEach((node) => {
+ if (node.classList?.contains(TIME_AGO_CLASS_NAME)) {
+ acc.push(node);
+ }
+ });
+
+ return acc;
+ }, []);
+};
+
+// Callback handler for mutations observed on the streaming element.
+const handleStreamingElementMutation = (mutationList) => {
+ const timestamps = findTimeAgoNodes(mutationList);
+ const timestampIntersectionObserver = new IntersectionObserver(handleTimestampsIntersecting, {
+ rootMargin: `${window.innerHeight}px 0px`,
+ });
+
+ timestamps.forEach((timestamp) => timestampIntersectionObserver.observe(timestamp));
+};
+
+// Finds the streaming element within a mutation list.
+const findStreamingElement = (mutationList) =>
+ mutationList.find((mutation) =>
+ [...mutation.addedNodes].find((node) => node.localName === STREAMING_ELEMENT_NAME),
+ )?.target;
+
+// Waits for the streaming element to become available on the rootElement.
+const waitForStreamingElement = (rootElement) => {
+ return new Promise((resolve) => {
+ let element = document.querySelector(STREAMING_ELEMENT_NAME);
+
+ if (element) {
+ resolve(element);
+ return;
+ }
+
+ const rootElementObserver = new MutationObserver((mutations) => {
+ element = findStreamingElement(mutations);
+ if (element) {
+ resolve(element);
+ rootElementObserver.disconnect();
+ }
+ });
+
+ rootElementObserver.observe(rootElement, { childList: true, subtree: true });
+ });
+};
+
+/**
+ * Ensures relative (timeago) timestamps that are streamed are formatted correctly.
+ *
+ * Example: `May 12, 2020` → `3 years ago`
+ */
+export const handleStreamedRelativeTimestamps = async (rootElement) => {
+ const streamingElement = await waitForStreamingElement(rootElement); // wait for streaming to start
+ const streamingElementObserver = new MutationObserver(handleStreamingElementMutation);
+
+ streamingElementObserver.observe(streamingElement, { childList: true, subtree: true });
+
+ return () => streamingElementObserver.disconnect();
+};
diff --git a/app/assets/javascripts/super_sidebar/components/brand_logo.vue b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
new file mode 100644
index 00000000000..c017fa8afa2
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/brand_logo.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import logo from '../../../../views/shared/_logo.svg?raw';
+
+export default {
+ logo,
+ i18n: {
+ homepage: __('Homepage'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ inject: ['rootPath'],
+ props: {
+ logoUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <a
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
+ class="tanuki-logo-container"
+ :href="rootPath"
+ :title="$options.i18n.homepage"
+ data-track-action="click_link"
+ data-track-label="gitlab_logo_link"
+ data-track-property="nav_core_menu"
+ >
+ <img
+ v-if="logoUrl"
+ data-testid="brand-header-custom-logo"
+ :src="logoUrl"
+ class="gl-h-6 gl-max-w-full"
+ />
+ <span v-else v-safe-html="$options.logo" data-testid="brand-header-default-logo"></span>
+ </a>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index ad2111140a1..c5f3410a68f 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -5,7 +5,6 @@ import { s__ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
import { trackContextAccess, formatContextSwitcherItems } from '../utils';
-import { maxSize, applyMaxSize } from '../popper_max_size_modifier';
import NavItem from './nav_item.vue';
import ProjectsList from './projects_list.vue';
import GroupsList from './groups_list.vue';
@@ -142,9 +141,6 @@ export default {
},
},
DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
- popperOptions: {
- modifiers: [maxSize, applyMaxSize],
- },
};
</script>
@@ -153,7 +149,6 @@ export default {
ref="disclosure-dropdown"
class="context-switcher gl-w-full"
placement="center"
- :popper-options="$options.popperOptions"
@shown="onDisclosureDropdownShown"
@hidden="onDisclosureDropdownHidden"
>
@@ -194,6 +189,7 @@ export default {
:key="item.link"
:item="item"
:link-classes="{ [item.link_classes]: item.link_classes }"
+ is-subitem
/>
</ul>
</li>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index cfb7e7732e9..17227a2b123 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -34,7 +34,7 @@ export default {
<template>
<button
type="button"
- class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0"
+ class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08! gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8 gl-flex-shrink-0"
data-qa-selector="context_switcher"
>
<span
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index fa6056aff5e..0ce856c9af8 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -42,21 +42,9 @@ export default {
isInvitedMembers(groupItem) {
return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
},
- closeAndFocus() {
- this.$refs.dropdown.closeAndFocus();
- },
},
toggleId: 'create-menu-toggle',
- popperOptions: {
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
- },
- },
- ],
- },
+ dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET },
TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
};
</script>
@@ -64,14 +52,13 @@ export default {
<template>
<div>
<gl-disclosure-dropdown
- ref="dropdown"
category="tertiary"
icon="plus"
no-caret
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
- :popper-options="$options.popperOptions"
+ :dropdown-offset="$options.dropdownOffset"
data-qa-selector="new_menu_toggle"
data-testid="new-menu-toggle"
@shown="dropdownOpen = true"
@@ -89,7 +76,6 @@ export default {
:key="`${groupItem.text}-trigger`"
trigger-source="top-nav"
:trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN"
- @modal-opened="closeAndFocus"
/>
<gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" />
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index 11bf2ddbd30..02adebc50af 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -1,13 +1,19 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import AccessorUtilities from '~/lib/utils/accessor';
+import { __ } from '~/locale';
import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
import ItemsList from './items_list.vue';
export default {
components: {
+ GlButton,
ItemsList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
title: {
type: String,
@@ -68,6 +74,9 @@ export default {
}
},
},
+ i18n: {
+ removeItem: __('Remove'),
+ },
};
</script>
@@ -87,7 +96,20 @@ export default {
>
{{ pristineText }}
</div>
- <items-list :aria-label="title" :items="cachedFrequentItems" @remove-item="handleItemRemove">
+ <items-list :aria-label="title" :items="cachedFrequentItems">
+ <template #actions="{ item }">
+ <gl-button
+ v-gl-tooltip.right.viewport
+ size="small"
+ category="tertiary"
+ icon="dash"
+ :aria-label="$options.i18n.removeItem"
+ :title="$options.i18n.removeItem"
+ class="gl-align-self-center gl-mr-2"
+ data-testid="item-remove"
+ @click.stop.prevent="handleItemRemove(item)"
+ />
+ </template>
<template #view-all-items>
<slot name="view-all-items"></slot>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
new file mode 100644
index 00000000000..96e6c9bab9e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue
@@ -0,0 +1,191 @@
+<script>
+import { debounce } from 'lodash';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { getFormattedItem } from '../utils';
+import {
+ COMMON_HANDLES,
+ COMMAND_HANDLE,
+ USER_HANDLE,
+ PROJECT_HANDLE,
+ ISSUE_HANDLE,
+ GLOBAL_COMMANDS_GROUP_TITLE,
+ PAGES_GROUP_TITLE,
+ GROUP_TITLES,
+} from './constants';
+import SearchItem from './search_item.vue';
+import { commandMapper, linksReducer, autocompleteQuery } from './utils';
+
+export default {
+ name: 'CommandPaletteItems',
+ components: {
+ GlDisclosureDropdownGroup,
+ GlLoadingIcon,
+ SearchItem,
+ },
+ inject: ['commandPaletteCommands', 'commandPaletteLinks', 'autocompletePath', 'searchContext'],
+ props: {
+ searchQuery: {
+ type: String,
+ required: true,
+ },
+ handle: {
+ type: String,
+ required: true,
+ validator: (value) => {
+ return COMMON_HANDLES.includes(value);
+ },
+ },
+ },
+ data: () => ({
+ groups: [],
+ error: null,
+ loading: false,
+ }),
+ computed: {
+ isCommandMode() {
+ return this.handle === COMMAND_HANDLE;
+ },
+ isUserMode() {
+ return this.handle === USER_HANDLE;
+ },
+ commands() {
+ return this.commandPaletteCommands.map(commandMapper);
+ },
+ links() {
+ return this.commandPaletteLinks.reduce(linksReducer, []);
+ },
+ filteredCommands() {
+ return this.searchQuery
+ ? this.commands
+ .map(({ name, items }) => {
+ return {
+ name: name || GLOBAL_COMMANDS_GROUP_TITLE,
+ items: this.filterBySearchQuery(items, 'text'),
+ };
+ })
+ .filter(({ items }) => items.length)
+ : this.commands;
+ },
+ hasResults() {
+ return this.groups?.length && this.groups.some((group) => group.items?.length);
+ },
+ hasSearchQuery() {
+ if (this.isCommandMode) {
+ return this.searchQuery?.length > 0;
+ }
+ return this.searchQuery?.length > 2;
+ },
+ searchTerm() {
+ if (this.handle === ISSUE_HANDLE) {
+ return `${ISSUE_HANDLE}${this.searchQuery}`;
+ }
+ return this.searchQuery;
+ },
+ },
+ watch: {
+ searchQuery: {
+ handler() {
+ switch (this.handle) {
+ case COMMAND_HANDLE:
+ this.getCommandsAndPages();
+ break;
+ case USER_HANDLE:
+ case PROJECT_HANDLE:
+ case ISSUE_HANDLE:
+ this.getScopedItems();
+ break;
+ default:
+ break;
+ }
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ filterBySearchQuery(items, key = 'keywords') {
+ return fuzzaldrinPlus.filter(items, this.searchQuery, { key });
+ },
+ getCommandsAndPages() {
+ if (!this.searchQuery) {
+ this.groups = [...this.commands];
+ return;
+ }
+ const matchedLinks = this.filterBySearchQuery(this.links);
+
+ if (this.filteredCommands.length || matchedLinks.length) {
+ this.groups = [];
+ }
+
+ if (this.filteredCommands.length) {
+ this.groups = [...this.filteredCommands];
+ }
+
+ if (matchedLinks.length) {
+ this.groups.push({
+ name: PAGES_GROUP_TITLE,
+ items: matchedLinks,
+ });
+ }
+ },
+ getScopedItems: debounce(function debouncedSearch() {
+ if (this.searchQuery && this.searchQuery.length < 3) return null;
+
+ this.loading = true;
+
+ return axios
+ .get(
+ autocompleteQuery({
+ path: this.autocompletePath,
+ searchTerm: this.searchTerm,
+ handle: this.handle,
+ projectId: this.searchContext.project?.id,
+ }),
+ )
+ .then(({ data }) => {
+ this.groups = this.getGroups(data);
+ })
+ .catch((error) => {
+ this.error = error;
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getGroups(data) {
+ return [
+ {
+ name: GROUP_TITLES[this.handle],
+ items: data.map(getFormattedItem),
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <gl-loading-icon v-if="loading" size="lg" class="gl-my-5" />
+
+ <template v-else-if="hasResults">
+ <gl-disclosure-dropdown-group
+ v-for="(group, index) in groups"
+ :key="index"
+ :group="group"
+ bordered
+ class="{'gl-mt-0!': index===0}"
+ >
+ <template #list-item="{ item }">
+ <search-item :item="item" :search-query="searchQuery" />
+ </template>
+ </gl-disclosure-dropdown-group>
+ </template>
+
+ <div v-else-if="hasSearchQuery && !hasResults" class="gl-text-gray-700 gl-pl-5 gl-py-3">
+ {{ __('No results found') }}
+ </div>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js
new file mode 100644
index 00000000000..9dab16984f5
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/constants.js
@@ -0,0 +1,45 @@
+import { s__, sprintf } from '~/locale';
+
+export const COMMAND_HANDLE = '>';
+export const USER_HANDLE = '@';
+export const PROJECT_HANDLE = '&';
+export const ISSUE_HANDLE = '#';
+
+export const COMMON_HANDLES = [COMMAND_HANDLE, USER_HANDLE, PROJECT_HANDLE, ISSUE_HANDLE];
+export const SEARCH_OR_COMMAND_MODE_PLACEHOLDER = sprintf(
+ s__(
+ 'CommandPalette|Type %{commandHandle} for command, %{userHandle} for user, %{projectHandle} for project, %{issueHandle} for issue or perform generic search...',
+ ),
+ {
+ commandHandle: COMMAND_HANDLE,
+ userHandle: USER_HANDLE,
+ issueHandle: ISSUE_HANDLE,
+ projectHandle: PROJECT_HANDLE,
+ },
+ false,
+);
+
+export const SEARCH_SCOPE_PLACEHOLDER = {
+ [COMMAND_HANDLE]: s__('CommandPalette|command'),
+ [USER_HANDLE]: s__('CommandPalette|user (enter at least 3 chars)'),
+ [PROJECT_HANDLE]: s__('CommandPalette|project (enter at least 3 chars)'),
+ [ISSUE_HANDLE]: s__('CommandPalette|issue (enter at least 3 chars)'),
+};
+
+export const SEARCH_SCOPE = {
+ [USER_HANDLE]: 'user',
+ [PROJECT_HANDLE]: 'project',
+ [ISSUE_HANDLE]: 'issue',
+};
+
+export const GLOBAL_COMMANDS_GROUP_TITLE = s__('CommandPalette|Global Commands');
+export const USERS_GROUP_TITLE = s__('GlobalSearch|Users');
+export const PAGES_GROUP_TITLE = s__('CommandPalette|Pages');
+export const PROJECTS_GROUP_TITLE = s__('GlobalSearch|Projects');
+export const ISSUE_GROUP_TITLE = s__('GlobalSearch|Recent issues');
+
+export const GROUP_TITLES = {
+ [USER_HANDLE]: USERS_GROUP_TITLE,
+ [PROJECT_HANDLE]: PROJECTS_GROUP_TITLE,
+ [ISSUE_HANDLE]: ISSUE_GROUP_TITLE,
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
new file mode 100644
index 00000000000..dce2b24f551
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue
@@ -0,0 +1,42 @@
+<script>
+import { COMMON_HANDLES, SEARCH_SCOPE_PLACEHOLDER } from './constants';
+
+export default {
+ name: 'FakeSearchInput',
+ props: {
+ userInput: {
+ type: String,
+ required: true,
+ },
+ scope: {
+ type: String,
+ required: true,
+ validator: (value) => COMMON_HANDLES.includes(value),
+ },
+ },
+ computed: {
+ placeholder() {
+ return SEARCH_SCOPE_PLACEHOLDER[this.scope];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-pointer-events-none fake-input">
+ <span class="gl-opacity-0" data-testid="search-scope">{{ scope }}&nbsp;</span>
+ <span
+ v-if="!userInput"
+ data-testid="search-scope-placeholder"
+ class="gl-text-gray-500 gl-pointer-events-none"
+ >{{ placeholder }}</span
+ >
+ </div>
+</template>
+
+<style scoped>
+.fake-input {
+ top: 12px;
+ left: 33px;
+}
+</style>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue
new file mode 100644
index 00000000000..b940c7c24c6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/search_item.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlAvatar, GlIcon } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import highlight from '~/lib/utils/highlight';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+
+export default {
+ name: 'CommandPaletteSearchItem',
+ components: {
+ GlAvatar,
+ GlIcon,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ searchQuery: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ highlightedName() {
+ return highlight(this.item.text, this.searchQuery);
+ },
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar
+ v-if="item.avatar_url !== undefined"
+ class="gl-mr-3"
+ :src="item.avatar_url"
+ :entity-id="item.entity_id"
+ :entity-name="item.entity_name"
+ :size="item.avatar_size"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ aria-hidden="true"
+ />
+ <gl-icon v-if="item.icon" class="gl-mr-3" :name="item.icon" />
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span v-safe-html="highlightedName" class="gl-text-gray-900"></span>
+ <span
+ v-if="item.namespace"
+ v-safe-html="item.namespace"
+ class="gl-font-sm gl-text-gray-500"
+ ></span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js
new file mode 100644
index 00000000000..5c8c0e59eaf
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/utils.js
@@ -0,0 +1,47 @@
+import { isNil, omitBy } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import { SEARCH_SCOPE } from './constants';
+
+export const commandMapper = ({ name, items }) => {
+ // TODO: we filter out invite_members for now, because it is complicated to add the invite members modal here
+ // and is out of scope for the basic command palette items. If it proves to be useful, we can add it later.
+ return {
+ name,
+ items: items.filter(({ component }) => component !== 'invite_members'),
+ };
+};
+
+export const linksReducer = (acc, menuItem) => {
+ acc.push({
+ text: menuItem.title,
+ keywords: menuItem.title,
+ icon: menuItem.icon,
+ href: menuItem.link,
+ });
+ if (menuItem.items?.length) {
+ const items = menuItem.items.map(({ title, link }) => ({
+ keywords: title,
+ text: [menuItem.title, title].join(' > '),
+ href: link,
+ icon: menuItem.icon,
+ }));
+
+ /* eslint-disable-next-line no-param-reassign */
+ acc = [...acc, ...items];
+ }
+ return acc;
+};
+
+export const autocompleteQuery = ({ path, searchTerm, handle, projectId }) => {
+ const query = omitBy(
+ {
+ term: searchTerm,
+ project_id: projectId,
+ filter: 'search',
+ scope: SEARCH_SCOPE[handle],
+ },
+ isNil,
+ );
+
+ return `${path}?${objectToQuery(query)}`;
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
index 55c28661440..cb34f2b8c26 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -24,6 +24,7 @@ import {
SEARCH_RESULTS_LOADING,
SEARCH_RESULTS_SCOPE,
} from '~/vue_shared/global_search/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
@@ -35,6 +36,9 @@ import {
SEARCH_INPUT_SELECTOR,
SEARCH_RESULTS_ITEM_SELECTOR,
} from '../constants';
+import CommandPaletteItems from '../command_palette/command_palette_items.vue';
+import FakeSearchInput from '../command_palette/fake_search_input.vue';
+import { COMMON_HANDLES, SEARCH_OR_COMMAND_MODE_PLACEHOLDER } from '../command_palette/constants';
import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
import GlobalSearchDefaultItems from './global_search_default_items.vue';
import GlobalSearchScopedItems from './global_search_scoped_items.vue';
@@ -60,7 +64,10 @@ export default {
GlIcon,
GlToken,
GlModal,
+ CommandPaletteItems,
+ FakeSearchInput,
},
+ mixins: [glFeatureFlagMixin()],
computed: {
...mapState(['search', 'loading', 'searchContext']),
...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
@@ -72,6 +79,9 @@ export default {
this.setSearch(value);
},
},
+ searchPlaceholder() {
+ return this.glFeatures?.commandPalette ? SEARCH_OR_COMMAND_MODE_PLACEHOLDER : SEARCH_GITLAB;
+ },
showDefaultItems() {
return !this.searchText;
},
@@ -104,7 +114,7 @@ export default {
};
},
showScopeHelp() {
- return this.searchTermOverMin;
+ return this.searchTermOverMin && !this.isCommandMode;
},
searchBarItem() {
return this.searchOptions?.[0];
@@ -120,10 +130,26 @@ export default {
scope: this.infieldHelpContent,
});
},
+
+ searchTextFirstChar() {
+ return this.searchText?.trim().charAt(0);
+ },
+ isCommandMode() {
+ return this.glFeatures?.commandPalette && COMMON_HANDLES.includes(this.searchTextFirstChar);
+ },
+ commandPaletteQuery() {
+ if (this.isCommandMode) {
+ return this.searchText?.trim().substring(1);
+ }
+ return '';
+ },
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ if (this.isCommandMode) {
+ return;
+ }
if (!searchTerm) {
this.clearAutocomplete();
} else {
@@ -222,12 +248,12 @@ export default {
>
<form
role="search"
- :aria-label="$options.i18n.SEARCH_GITLAB"
+ :aria-label="searchPlaceholder"
class="gl-relative gl-rounded-base gl-w-full"
:class="searchBarClasses"
data-testid="global-search-form"
>
- <div class="gl-p-1">
+ <div class="gl-p-1 gl-relative">
<gl-search-box-by-type
id="search"
ref="searchInputBox"
@@ -236,7 +262,7 @@ export default {
data-testid="global-search-input"
data-qa-selector="global_search_input"
autocomplete="off"
- :placeholder="$options.i18n.SEARCH_GITLAB"
+ :placeholder="searchPlaceholder"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
borderless
@input="getAutocompleteOptions"
@@ -266,6 +292,13 @@ export default {
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
{{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }}
</span>
+
+ <fake-search-input
+ v-if="isCommandMode"
+ :user-input="commandPaletteQuery"
+ :scope="searchTextFirstChar"
+ class="gl-absolute"
+ />
</div>
<span
role="region"
@@ -282,13 +315,20 @@ export default {
class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
@keydown="onKeydown"
>
- <global-search-default-items v-if="showDefaultItems" />
+ <command-palette-items
+ v-if="isCommandMode"
+ :search-query="commandPaletteQuery"
+ :handle="searchTextFirstChar"
+ />
+
<template v-else>
- <global-search-scoped-items v-if="showScopedSearchItems" />
- <global-search-autocomplete-items />
+ <global-search-default-items v-if="showDefaultItems" />
+ <template v-else>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
+ </template>
</template>
</div>
-
<template v-if="searchContext">
<input
v-if="searchContext.group"
diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue
index 4fa15f1cd76..48becacebb7 100644
--- a/app/assets/javascripts/super_sidebar/components/groups_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -64,7 +64,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item v-bind="viewAllProps" />
+ <nav-item v-bind="viewAllProps" is-subitem />
</template>
</search-results>
<frequent-items-list
@@ -75,7 +75,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item v-bind="viewAllProps" />
+ <nav-item v-bind="viewAllProps" is-subitem />
</template>
</frequent-items-list>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
index 1fffbb05d03..1d4c24c6853 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -8,7 +8,7 @@ import {
} from '@gitlab/ui';
import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { FORUM_URL, DOCS_URL, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
import Tracking from '~/tracking';
@@ -70,7 +70,7 @@ export default {
helpLinks: {
items: [
this.sidebarData.show_tanuki_bot && {
- icon: 'tanuki',
+ icon: 'tanuki-ai',
text: this.$options.i18n.chat,
action: this.showTanukiBotChat,
extraAttrs: {
@@ -93,7 +93,7 @@ export default {
},
{
text: this.$options.i18n.docs,
- href: `https://docs.${DOMAIN}`,
+ href: DOCS_URL,
extraAttrs: {
...this.trackingAttrs('gitlab_documentation'),
},
@@ -107,7 +107,7 @@ export default {
},
{
text: this.$options.i18n.forum,
- href: `https://forum.${DOMAIN}/`,
+ href: FORUM_URL,
extraAttrs: {
...this.trackingAttrs('community_forum'),
},
@@ -132,7 +132,7 @@ export default {
items: [
{
text: this.$options.i18n.shortcuts,
- action: this.showKeyboardShortcuts,
+ action: () => {},
extraAttrs: {
class: 'js-shortcuts-modal-trigger',
'data-track-action': 'click_button',
@@ -172,18 +172,11 @@ export default {
return true;
},
- showKeyboardShortcuts() {
- this.$refs.dropdown.close();
- },
-
showTanukiBotChat() {
- this.$refs.dropdown.close();
-
this.helpCenterState.showTanukiBotChatDrawer = true;
},
async showWhatsNew() {
- this.$refs.dropdown.close();
this.showWhatsNewNotification = false;
if (!this.toggleWhatsNewDrawer) {
@@ -211,29 +204,23 @@ export default {
});
},
},
- popperOptions: {
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
- },
- },
- ],
- },
+ dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET },
};
</script>
<template>
<gl-disclosure-dropdown
- ref="dropdown"
- :popper-options="$options.popperOptions"
+ :dropdown-offset="$options.dropdownOffset"
@shown="trackDropdownToggle(true)"
@hidden="trackDropdownToggle(false)"
>
<template #toggle>
<gl-button category="tertiary" icon="question-o" class="btn-with-notification">
- <span v-if="showWhatsNewNotification" class="notification-dot-info"></span>
+ <span
+ v-if="showWhatsNewNotification"
+ data-testid="notification-dot"
+ class="notification-dot-info"
+ ></span>
{{ $options.i18n.help }}
</gl-button>
</template>
@@ -263,7 +250,7 @@ export default {
<template #list-item="{ item }">
<span class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
{{ item.text }}
- <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-orange-500" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-purple-600" />
</span>
</template>
</gl-disclosure-dropdown-group>
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index ef27251dc6c..7d5af883651 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -1,17 +1,12 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import NavItem from './nav_item.vue';
export default {
components: {
- GlButton,
ProjectAvatar,
NavItem,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
items: {
type: Array,
@@ -29,6 +24,7 @@ export default {
:key="item.id"
:item="item"
:link-classes="{ 'gl-py-2!': true }"
+ is-subitem
>
<template #icon>
<project-avatar
@@ -37,20 +33,11 @@ export default {
:project-avatar-url="item.avatar"
:size="24"
aria-hidden="true"
+ class="gl-mr-n2"
/>
</template>
<template #actions>
- <gl-button
- v-gl-tooltip.right.viewport
- size="small"
- category="tertiary"
- icon="dash"
- :aria-label="__('Remove')"
- :title="__('Remove')"
- class="gl-align-self-center gl-p-1! gl-absolute gl-right-4"
- data-testid="item-remove"
- @click.stop.prevent="$emit('remove-item', item)"
- />
+ <slot name="actions" :item="item"></slot>
</template>
</nav-item>
<slot name="view-all-items"></slot>
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
index 93c249dffeb..b5a8241a286 100644
--- a/app/assets/javascripts/super_sidebar/components/menu_section.vue
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -71,7 +71,7 @@ export default {
<component :is="tag">
<hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
<button
- class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-line-height-normal gl-mb-2 gl-py-3 gl-px-0 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
:class="computedLinkClasses"
data-qa-selector="menu_section_button"
:data-qa-section-name="item.title"
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index ec1c4069b1a..0ee9db10ee2 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -51,6 +51,11 @@ export default {
required: false,
default: () => ({}),
},
+ isSubitem: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pillData() {
@@ -99,6 +104,7 @@ export default {
return {
'gl-py-2': this.isPinnable,
'gl-py-3': !this.isPinnable,
+ 'gl-mx-2': this.isSubitem,
[this.item.link_classes]: this.item.link_classes,
...this.linkClasses,
};
@@ -106,6 +112,9 @@ export default {
navItemLinkComponent() {
return this.item.to ? NavItemRouterLink : NavItemLink;
},
+ iconClasses() {
+ return this.isSubitem === true ? 'gl-ml-2 gl-mr-4' : 'gl-w-6 gl-mx-3';
+ },
},
};
</script>
@@ -128,7 +137,7 @@ export default {
style="width: 3px; border-radius: 3px; margin-right: 1px"
data-testid="active-indicator"
></div>
- <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
+ <div :class="iconClasses" class="gl-flex-shrink-0">
<slot name="icon">
<gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
<gl-icon
@@ -138,14 +147,14 @@ export default {
/>
</slot>
</div>
- <div class="gl-pr-8 gl-text-gray-900 gl-truncate-end">
+ <div class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end">
{{ item.title }}
<div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end">
{{ item.subtitle }}
</div>
</div>
<slot name="actions"></slot>
- <span v-if="hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-relative">
+ <span v-if="hasPill || isPinnable" class="gl-text-right gl-mr-3 gl-relative">
<gl-badge
v-if="hasPill"
size="sm"
diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
index 78860e35eb1..8d1a5c825b5 100644
--- a/app/assets/javascripts/super_sidebar/components/projects_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -65,7 +65,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item v-bind="viewAllProps" />
+ <nav-item v-bind="viewAllProps" is-subitem />
</template>
</search-results>
<frequent-items-list
@@ -76,7 +76,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item v-bind="viewAllProps" />
+ <nav-item v-bind="viewAllProps" is-subitem />
</template>
</frequent-items-list>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index 08af9232107..287e4f57d01 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,6 +1,7 @@
<script>
import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
+import { s__ } from '~/locale';
import { PANELS_WITH_PINS } from '../constants';
import NavItem from './nav_item.vue';
import PinnedSection from './pinned_section.vue';
@@ -42,6 +43,10 @@ export default {
},
},
+ i18n: {
+ mainNavigation: s__('Navigation|Main navigation'),
+ },
+
data() {
return {
// This is used as a provide and injected into the nav items.
@@ -137,8 +142,8 @@ export default {
</script>
<template>
- <nav class="gl-p-2 gl-relative">
- <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0">
+ <nav :aria-label="$options.i18n.mainNavigation" class="gl-p-2 gl-relative">
+ <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0" data-testid="static-items-section">
<nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static />
</ul>
<pinned-section
@@ -154,7 +159,7 @@ export default {
class="gl-my-2 gl-mx-4"
data-testid="main-menu-separator"
/>
- <ul class="gl-p-0 gl-list-style-none">
+ <ul class="gl-p-0 gl-list-style-none" data-testid="non-static-items-section">
<template v-for="item in nonStaticItems">
<menu-section
v-if="isSection(item)"
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index 768914584e8..d3b2143aaa7 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,13 +1,12 @@
<script>
import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
-import SafeHtml from '~/vue_shared/directives/safe_html';
import {
destroyUserCountsManager,
createUserCountsManager,
userCounts,
} from '~/super_sidebar/user_counts_manager';
-import logo from '../../../../views/shared/_logo.svg';
+import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue';
import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
@@ -20,7 +19,6 @@ export default {
// "GitLab Next" is a proper noun, so don't translate "Next"
/* eslint-disable-next-line @gitlab/require-i18n-strings */
NEXT_LABEL: 'Next',
- logo,
JS_TOGGLE_COLLAPSE_CLASS,
SEARCH_MODAL_ID,
components: {
@@ -35,6 +33,7 @@ export default {
/* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
),
SuperSidebarToggle,
+ BrandLogo,
},
i18n: {
createNew: __('Create new...'),
@@ -53,9 +52,8 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
- SafeHtml,
},
- inject: ['rootPath', 'isImpersonating'],
+ inject: ['isImpersonating'],
props: {
hasCollapseButton: {
default: true,
@@ -107,23 +105,7 @@ export default {
<template>
<div class="user-bar">
<div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2">
- <a
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
- class="tanuki-logo-container"
- :href="rootPath"
- :title="$options.i18n.homepage"
- data-track-action="click_link"
- data-track-label="gitlab_logo_link"
- data-track-property="nav_core_menu"
- >
- <img
- v-if="sidebarData.logo_url"
- data-testid="brand-header-custom-logo"
- :src="sidebarData.logo_url"
- class="gl-h-6"
- />
- <span v-else v-safe-html="$options.logo"></span>
- </a>
+ <brand-logo :logo-url="sidebarData.logo_url" />
<gl-badge
v-if="sidebarData.gitlab_com_and_canary"
variant="success"
@@ -168,6 +150,7 @@ export default {
category="tertiary"
data-method="delete"
data-testid="stop-impersonation-btn"
+ data-qa-selector="stop_impersonation_link"
/>
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index cd5a83c86cc..7d4991fbe96 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -221,16 +221,7 @@ export default {
});
},
},
- popperOptions: {
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
- },
- },
- ],
- },
+ dropdownOffset: { mainAxis: DROPDOWN_Y_OFFSET, crossAxis: DROPDOWN_X_OFFSET },
};
</script>
@@ -238,9 +229,10 @@ export default {
<div>
<gl-disclosure-dropdown
ref="userDropdown"
- :popper-options="$options.popperOptions"
+ :dropdown-offset="$options.dropdownOffset"
data-testid="user-dropdown"
data-qa-selector="user_menu"
+ :auto-close="false"
@shown="onShow"
>
<template #toggle>
diff --git a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
deleted file mode 100644
index 6581d521107..00000000000
--- a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { detectOverflow } from '@popperjs/core';
-
-/**
- * These modifiers were copied from the community modifier popper-max-size-modifier
- * https://www.npmjs.com/package/popper-max-size-modifier.
- * We are considering upgrading Popper.js to Floating UI, at which point the behavior this
- * introduces will be available out of the box.
- * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213
- */
-
-export const maxSize = {
- name: 'maxSize',
- enabled: true,
- phase: 'main',
- requiresIfExists: ['offset', 'preventOverflow', 'flip'],
- fn({ state, name }) {
- const overflow = detectOverflow(state);
- const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 };
- const { width, height } = state.rects.popper;
- const [basePlacement] = state.placement.split('-');
-
- const widthProp = basePlacement === 'left' ? 'left' : 'right';
- const heightProp = basePlacement === 'top' ? 'top' : 'bottom';
-
- state.modifiersData[name] = {
- width: width - overflow[widthProp] - x,
- height: height - overflow[heightProp] - y,
- };
- },
-};
-
-export const applyMaxSize = {
- name: 'applyMaxSize',
- enabled: true,
- phase: 'write',
- requires: ['maxSize'],
- fn({ state }) {
- // The `maxSize` modifier provides this data
- const { width, height } = state.modifiersData.maxSize;
- state.elements.popper.style.maxWidth = `${width}px`;
- state.elements.popper.style.maxHeight = `${height}px`;
- },
-};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 63424277ffc..f6afde02fa5 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -72,6 +72,8 @@ export const initSuperSidebar = () => {
const sidebarData = JSON.parse(sidebar);
const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+ const commandPaletteCommands = sidebarData.create_new_menu_groups || [];
+ const commandPaletteLinks = convertObjectPropsToCamelCase(sidebarData.current_menu_items || []);
const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
const isImpersonating = parseBoolean(sidebarData.is_impersonating);
@@ -85,6 +87,10 @@ export const initSuperSidebar = () => {
toggleNewNavEndpoint,
isImpersonating,
...getTrialStatusWidgetData(sidebarData),
+ commandPaletteCommands,
+ commandPaletteLinks,
+ autocompletePath,
+ searchContext,
},
store: createStore({
searchPath,
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
index 1a359533435..2687ea5ccf8 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -24,7 +24,7 @@ export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
sidebarState.isPeek = false;
- sidebarState.isPeekable = Boolean(gon.features?.superSidebarPeek) && collapsed;
+ sidebarState.isPeekable = collapsed;
sidebarState.isCollapsed = collapsed;
if (saveCookie && isDesktopBreakpoint()) {
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 6e90ad2e0fd..333059b5340 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg';
+import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg?raw';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, __ } from '~/locale';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue
index 036ce2cca78..bb4f3ac0571 100644
--- a/app/assets/javascripts/tags/components/sort_dropdown.vue
+++ b/app/assets/javascripts/tags/components/sort_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui';
import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -8,8 +8,7 @@ export default {
searchPlaceholder: s__('TagsPage|Filter by tag name'),
},
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlSearchBoxByClick,
},
inject: ['sortOptions', 'filterTagsPath'],
@@ -23,6 +22,11 @@ export default {
selectedSortMethod() {
return this.sortOptions[this.selectedKey];
},
+ sortOptionsListboxItems() {
+ return Object.entries(this.sortOptions).map(([value, text]) => {
+ return { value, text };
+ });
+ },
},
created() {
const sortValue = getParameterValues('sort');
@@ -37,9 +41,6 @@ export default {
}
},
methods: {
- isSortMethodSelected(sortKey) {
- return sortKey === this.selectedKey;
- },
visitUrlFromOption(sortKey) {
this.selectedKey = sortKey;
const urlParams = {};
@@ -62,16 +63,13 @@ export default {
data-testid="tag-search"
@submit="visitUrlFromOption(selectedKey)"
/>
- <gl-dropdown :text="selectedSortMethod" right data-testid="tags-dropdown">
- <gl-dropdown-item
- v-for="(value, key) in sortOptions"
- :key="key"
- :is-checked="isSortMethodSelected(key)"
- is-check-item
- @click="visitUrlFromOption(key)"
- >
- {{ value }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ v-model="selectedKey"
+ data-testid="tags-dropdown"
+ :items="sortOptionsListboxItems"
+ placement="right"
+ :toggle-text="selectedSortMethod"
+ @select="visitUrlFromOption"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js
new file mode 100644
index 00000000000..a572d5af31b
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.stories.js
@@ -0,0 +1,46 @@
+import SectionedPercentageBar from './sectioned_percentage_bar.vue';
+
+export default {
+ component: SectionedPercentageBar,
+ title: 'usage_quotas/sectioned_percentage_bar',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { SectionedPercentageBar },
+ props: Object.keys(argTypes),
+ template: '<sectioned-percentage-bar :sections="sections" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ sections: [
+ {
+ id: 'artifacts',
+ label: 'Artifacts',
+ value: 2000,
+ formattedValue: '1.95 KiB',
+ cssClasses: 'gl-bg-data-viz-blue-500',
+ },
+ {
+ id: 'repository',
+ label: 'Repository',
+ value: 4000,
+ formattedValue: '3.90 KiB',
+ cssClasses: 'gl-bg-data-viz-orange-500',
+ },
+ {
+ id: 'packages',
+ label: 'Packages',
+ value: 3000,
+ formattedValue: '2.93 KiB',
+ cssClasses: 'gl-bg-data-viz-aqua-500',
+ },
+ {
+ id: 'registry',
+ label: 'Registry',
+ value: 5000,
+ formattedValue: '4.88 KiB',
+ cssClasses: 'gl-bg-data-viz-green-500',
+ },
+ ],
+};
diff --git a/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue
new file mode 100644
index 00000000000..3d9ce591450
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/components/sectioned_percentage_bar.vue
@@ -0,0 +1,87 @@
+<script>
+import { colorFromDefaultPalette } from '@gitlab/ui/dist/utils/charts/theme';
+import { roundOffFloat } from '~/lib/utils/common_utils';
+import { formatNumber } from '~/locale';
+
+export default {
+ props: {
+ /**
+ * {
+ * id: string;
+ * label: string;
+ * value: number;
+ * formattedValue: number | string;
+ * }[]
+ */
+ sections: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ sectionsCombinedValue() {
+ return this.sections.reduce((accumulator, section) => {
+ return accumulator + section.value;
+ }, 0);
+ },
+ computedSections() {
+ return this.sections.map((section, index) => {
+ const percentage = section.value / this.sectionsCombinedValue;
+
+ return {
+ ...section,
+ backgroundColor: colorFromDefaultPalette(index),
+ cssPercentage: `${roundOffFloat(percentage * 100, 4)}%`,
+ srLabelPercentage: formatNumber(percentage, {
+ style: 'percent',
+ minimumFractionDigits: 1,
+ }),
+ };
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-rounded-pill gl-overflow-hidden gl-w-full">
+ <div
+ v-for="{ id, label, backgroundColor, cssPercentage, srLabelPercentage } in computedSections"
+ :key="id"
+ class="gl-h-5"
+ :style="{
+ backgroundColor,
+ width: cssPercentage,
+ }"
+ :data-testid="`percentage-bar-section-${id}`"
+ >
+ <span class="gl-sr-only">{{ label }} {{ srLabelPercentage }}</span>
+ </div>
+ </div>
+ <div class="gl-mt-5">
+ <div class="gl-display-flex gl-align-items-center gl-flex-wrap gl-my-n3 gl-mx-n3">
+ <div
+ v-for="{ id, label, backgroundColor, formattedValue } in computedSections"
+ :key="id"
+ class="gl-display-flex gl-align-items-center gl-p-3"
+ :data-testid="`percentage-bar-legend-section-${id}`"
+ >
+ <div
+ class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block"
+ :style="{ backgroundColor }"
+ data-testid="legend-section-color"
+ ></div>
+ <p class="gl-m-0 gl-font-sm">
+ <span class="gl-mr-2 gl-font-weight-bold">
+ {{ label }}
+ </span>
+ <span class="gl-text-gray-500">
+ {{ formattedValue }}
+ </span>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
index beff3b4c0c3..ce487beca07 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -82,7 +82,15 @@ export default {
/>
<div>
<p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
- {{ item.storageType.name }}
+ <gl-link
+ v-if="item.storageType.detailsPath && item.value"
+ :data-testid="`${item.storageType.id}-details-link`"
+ :href="item.storageType.detailsPath"
+ >{{ item.storageType.name }}</gl-link
+ >
+ <template v-else>
+ {{ item.storageType.name }}
+ </template>
<gl-link
v-if="item.storageType.helpPath"
:href="item.storageType.helpPath"
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
index 5142c2c0915..70dd1a841b2 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
@@ -14,10 +14,10 @@ export default {
iconName(storageTypeName) {
const defaultStorageTypeIcon = 'disk';
const storageTypeIconMap = {
- lfsObjectsSize: 'doc-image',
- snippetsSize: 'snippet',
- repositorySize: 'infrastructure-registry',
- packagesSize: 'package',
+ lfsObjects: 'doc-image',
+ snippets: 'snippet',
+ repository: 'infrastructure-registry',
+ packages: 'package',
};
return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon;
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
index e9683924ff8..cdaba2ad3f9 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -1,11 +1,10 @@
<script>
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECT_STORAGE_TYPES } from '../constants';
import { descendingStorageUsageSort } from '../utils';
export default {
- mixins: [glFeatureFlagMixin()],
+ name: 'UsageGraph',
props: {
rootStorageStatistics: {
required: true,
@@ -36,49 +35,49 @@ export default {
return [
{
- id: 'repositorySize',
+ id: 'repository',
style: this.usageStyle(this.barRatio(repositorySize)),
class: 'gl-bg-data-viz-blue-500',
size: repositorySize,
},
{
- id: 'lfsObjectsSize',
+ id: 'lfsObjects',
style: this.usageStyle(this.barRatio(lfsObjectsSize)),
class: 'gl-bg-data-viz-orange-600',
size: lfsObjectsSize,
},
{
- id: 'packagesSize',
+ id: 'packages',
style: this.usageStyle(this.barRatio(packagesSize)),
class: 'gl-bg-data-viz-aqua-500',
size: packagesSize,
},
{
- id: 'containerRegistrySize',
+ id: 'containerRegistry',
style: this.usageStyle(this.barRatio(containerRegistrySize)),
class: 'gl-bg-data-viz-aqua-800',
size: containerRegistrySize,
},
{
- id: 'buildArtifactsSize',
+ id: 'buildArtifacts',
style: this.usageStyle(this.barRatio(buildArtifactsSize)),
class: 'gl-bg-data-viz-green-500',
size: buildArtifactsSize,
},
{
- id: 'pipelineArtifactsSize',
+ id: 'pipelineArtifacts',
style: this.usageStyle(this.barRatio(pipelineArtifactsSize)),
class: 'gl-bg-data-viz-green-800',
size: pipelineArtifactsSize,
},
{
- id: 'wikiSize',
+ id: 'wiki',
style: this.usageStyle(this.barRatio(wikiSize)),
class: 'gl-bg-data-viz-magenta-500',
size: wikiSize,
},
{
- id: 'snippetsSize',
+ id: 'snippets',
style: this.usageStyle(this.barRatio(snippetsSize)),
class: 'gl-bg-data-viz-orange-800',
size: snippetsSize,
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index 8e3eaff4496..f08e8db26b9 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -26,44 +26,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
export const PROJECT_STORAGE_TYPES = [
{
- id: 'containerRegistrySize',
+ id: 'containerRegistry',
name: __('Container Registry'),
description: s__(
'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
),
},
{
- id: 'buildArtifactsSize',
+ id: 'buildArtifacts',
name: __('Job artifacts'),
description: s__('UsageQuota|Job artifacts created by CI/CD.'),
},
{
- id: 'pipelineArtifactsSize',
+ id: 'pipelineArtifacts',
name: __('Pipeline artifacts'),
description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'),
},
{
- id: 'lfsObjectsSize',
+ id: 'lfsObjects',
name: __('LFS'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
- id: 'packagesSize',
+ id: 'packages',
name: __('Packages'),
description: s__('UsageQuota|Code packages and container images.'),
},
{
- id: 'repositorySize',
+ id: 'repository',
name: __('Repository'),
description: s__('UsageQuota|Git repository.'),
},
{
- id: 'snippetsSize',
+ id: 'snippets',
name: __('Snippets'),
description: s__('UsageQuota|Shared bits of code and text.'),
},
{
- id: 'wikiSize',
+ id: 'wiki',
name: __('Wiki'),
description: s__('UsageQuota|Wiki content.'),
},
diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
index d254f576219..85a181d3e01 100644
--- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
+++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
@@ -1,6 +1,14 @@
query getProjectStorageStatistics($fullPath: ID!) {
project(fullPath: $fullPath) {
id
+ statisticsDetailsPaths {
+ containerRegistry
+ buildArtifacts
+ packages
+ repository
+ snippets
+ wiki
+ }
statistics {
containerRegistrySize
buildArtifactsSize
diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js
index 443788f650d..0460cd0a9b2 100644
--- a/app/assets/javascripts/usage_quotas/storage/utils.js
+++ b/app/assets/javascripts/usage_quotas/storage/utils.js
@@ -1,17 +1,23 @@
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { PROJECT_STORAGE_TYPES } from './constants';
-export const getStorageTypesFromProjectStatistics = (projectStatistics, helpLinks = {}) =>
+export const getStorageTypesFromProjectStatistics = (
+ projectStatistics,
+ helpLinks = {},
+ statisticsDetailsPaths = {},
+) =>
PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
- const helpPathKey = currentType.id.replace(`Size`, ``);
- const helpPath = helpLinks[helpPathKey];
+ const helpPath = helpLinks[currentType.id];
+ const value = projectStatistics[`${currentType.id}Size`];
+ const detailsPath = statisticsDetailsPaths[currentType.id];
return types.concat({
storageType: {
...currentType,
helpPath,
+ detailsPath,
},
- value: projectStatistics[currentType.id],
+ value,
});
}, []);
@@ -27,7 +33,11 @@ export const parseGetProjectStorageResults = (data, helpLinks) => {
return {};
}
const { storageSize } = projectStatistics;
- const storageTypes = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks);
+ const storageTypes = getStorageTypesFromProjectStatistics(
+ projectStatistics,
+ helpLinks,
+ data?.project?.statisticsDetailsPaths,
+ );
return {
storage: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index 25cf5335fb5..95fa01c23f1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -7,7 +7,6 @@ import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__, __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import StateContainer from '../state_container.vue';
import { INVALID_RULES_DOCS_PATH } from '../../constants';
@@ -187,7 +186,7 @@ export default {
this.updateApproval(
() => this.service.approveMergeRequestWithAuth(data),
(error) => {
- if (error && error.response && error.response.status === HTTP_STATUS_UNAUTHORIZED) {
+ if (error?.response?.status === HTTP_STATUS_UNAUTHORIZED) {
this.hasApprovalAuthError = true;
return;
}
@@ -215,11 +214,6 @@ export default {
this.clearError();
return serviceFn()
.then(() => {
- if (!window.gon?.features?.realtimeMrStatusChange) {
- eventHub.$emit('MRWidgetUpdateRequested');
- eventHub.$emit('ApprovalUpdated');
- }
-
// TODO: Remove this line when we move to Apollo subscriptions
this.$apollo.queries.approvals.refetch();
})
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
index bdd46d6a656..a3d5a6bed11 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -1,7 +1,6 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { RUNNING } from './constants';
+import { RUNNING, WILL_DEPLOY } from './constants';
export default {
name: 'DeploymentActionButton',
@@ -42,40 +41,50 @@ export default {
},
computed: {
isActionInProgress() {
- return Boolean(this.computedDeploymentStatus === RUNNING || this.actionInProgress);
- },
- actionInProgressTooltip() {
- switch (this.actionInProgress) {
- case this.actionsConfiguration.actionName:
- return this.actionsConfiguration.busyText;
- case null:
- return '';
- default:
- return __('Another action is currently in progress');
- }
+ return Boolean(
+ this.computedDeploymentStatus === RUNNING ||
+ this.computedDeploymentStatus === WILL_DEPLOY ||
+ this.actionInProgress,
+ );
},
isLoading() {
- return this.actionInProgress === this.actionsConfiguration.actionName;
+ return (
+ this.actionInProgress === this.actionsConfiguration.actionName ||
+ this.computedDeploymentStatus === WILL_DEPLOY
+ );
},
},
};
</script>
<template>
- <span v-gl-tooltip :title="actionInProgressTooltip" class="gl-display-inline-block" tabindex="0">
- <gl-button
- v-gl-tooltip
- category="primary"
- size="small"
- :title="buttonTitle"
- :aria-label="buttonTitle"
- :loading="isLoading"
- :disabled="isActionInProgress"
- :class="`inline gl-ml-3 ${containerClasses}`"
- :icon="icon"
- @click="$emit('click')"
- >
- <slot> </slot>
- </gl-button>
- </span>
+ <gl-button
+ v-if="isLoading || isActionInProgress"
+ category="primary"
+ size="small"
+ :title="buttonTitle"
+ :aria-label="buttonTitle"
+ :loading="isLoading"
+ :disabled="isActionInProgress"
+ :class="`inline gl-ml-3 ${containerClasses}`"
+ :icon="icon"
+ @click="$emit('click')"
+ >
+ <slot> </slot>
+ </gl-button>
+ <gl-button
+ v-else
+ v-gl-tooltip.hover
+ category="primary"
+ size="small"
+ :title="buttonTitle"
+ :aria-label="buttonTitle"
+ :loading="isLoading"
+ :disabled="isActionInProgress"
+ :class="`inline gl-ml-3 ${containerClasses}`"
+ :icon="icon"
+ @click="$emit('click')"
+ >
+ <slot> </slot>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index 306ed664326..e79d2db4b5a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -71,11 +71,25 @@ export default {
return this.deployment.details?.playable_build?.play_path;
},
redeployPath() {
+ if (this.redeployMrWidgetFeatureFlagEnabled) {
+ return this.deployment.retry_url;
+ }
return this.deployment.details?.playable_build?.retry_path;
},
stopUrl() {
return this.deployment.stop_url;
},
+ environmentAvailable() {
+ return Boolean(this.deployment.environment_available);
+ },
+ redeployMrWidgetFeatureFlagEnabled() {
+ return this.glFeatures.reviewAppsRedeployMrWidget;
+ },
+ showDeploymentActionButton() {
+ return (
+ this.redeployPath && !this.environmentAvailable && this.redeployMrWidgetFeatureFlagEnabled
+ );
+ },
},
actionsConfiguration: {
[STOPPING]: {
@@ -124,6 +138,10 @@ export default {
MRWidgetService.executeInlineAction(endpoint)
.then((resp) => {
+ if (this.redeployMrWidgetFeatureFlagEnabled) {
+ return;
+ }
+
const redirectUrl = resp?.data?.redirect_url;
if (redirectUrl) {
visitUrl(redirectUrl);
@@ -167,7 +185,7 @@ export default {
<span>{{ $options.actionsConfiguration[constants.DEPLOYING].buttonText }}</span>
</deployment-action-button>
<deployment-action-button
- v-if="canBeManuallyRedeployed"
+ v-if="canBeManuallyRedeployed && !redeployMrWidgetFeatureFlagEnabled"
:action-in-progress="actionInProgress"
:actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]"
:computed-deployment-status="computedDeploymentStatus"
@@ -178,12 +196,12 @@ export default {
<span>{{ $options.actionsConfiguration[constants.REDEPLOYING].buttonText }}</span>
</deployment-action-button>
<deployment-view-button
- v-if="hasExternalUrls"
+ v-if="hasExternalUrls && environmentAvailable"
:app-button-text="appButtonText"
:deployment="deployment"
/>
<deployment-action-button
- v-if="stopUrl"
+ v-if="stopUrl && environmentAvailable"
:action-in-progress="actionInProgress"
:computed-deployment-status="computedDeploymentStatus"
:actions-configuration="$options.actionsConfiguration[constants.STOPPING]"
@@ -192,5 +210,15 @@ export default {
container-classes="js-stop-env"
@click="stopEnvironment"
/>
+ <deployment-action-button
+ v-if="showDeploymentActionButton"
+ :action-in-progress="actionInProgress"
+ :computed-deployment-status="computedDeploymentStatus"
+ :actions-configuration="$options.actionsConfiguration[constants.REDEPLOYING]"
+ :button-title="$options.actionsConfiguration[constants.REDEPLOYING].buttonText"
+ :icon="$options.btnIcons.repeat"
+ container-classes="js-redeploy-action"
+ @click="redeploy"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index 17c51bc4e6e..9258bc39bcb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -55,7 +55,6 @@ export default {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
- MergeRequest.hideCloseButton();
MergeRequest.decreaseCounter();
stopPolling();
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue
new file mode 100644
index 00000000000..1dc4270f054
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue
@@ -0,0 +1,23 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+
+import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '../../i18n';
+
+export default {
+ name: 'MRWidgetPreparing',
+ i18n: {
+ preparing: MR_WIDGET_PREPARING_ASYNCHRONOUSLY,
+ },
+ components: {
+ GlLoadingIcon,
+ },
+};
+</script>
+<template>
+ <div class="gl-w-full gl-display-flex gl-p-4">
+ <gl-loading-icon size="md" class="gl-pr-4" inline />
+ <div class="gl-display-flex gl-align-items-center">
+ {{ $options.i18n.preparing }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index 30cd9fa752f..e1c54a8827c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,27 +1,14 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-merge-requests-md.svg?url';
-import api from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
export default {
name: 'MRWidgetNothingToMerge',
components: {
- GlButton,
GlSprintf,
GlLink,
},
- props: {
- mr: {
- type: Object,
- required: true,
- },
- },
- methods: {
- onClickNewFile() {
- api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file');
- },
- },
ciHelpPage: helpPagePath('ci/quick_start/index.html'),
EMPTY_STATE_SVG_URL,
};
@@ -31,42 +18,30 @@ export default {
<div class="mr-widget-body mr-widget-empty-state">
<div class="row">
<div
- class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-150 pb-0 pt-0"
+ class="col-md-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-130 pb-0 pt-0"
>
- <img
- :alt="s__('mrWidgetNothingToMerge|This merge request contains no changes.')"
- :src="$options.EMPTY_STATE_SVG_URL"
- />
+ <img :src="$options.EMPTY_STATE_SVG_URL" :alt="''" />
</div>
<div class="text col-md-9 col-12">
- <p class="highlight">
- {{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }}
+ <p class="highlight mt-3">
+ {{ s__('mrWidgetNothingToMerge|Merge request contains no changes') }}
</p>
<p data-testid="nothing-to-merge-body">
<gl-sprintf
:message="
s__(
- 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch. With %{linkStart}CI/CD%{linkEnd}, automatically test your changes before merging.',
+ 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the %{boldStart}Code%{boldEnd} dropdown list above, then test them with %{linkStart}CI/CD%{linkEnd} before merging.',
)
"
>
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
<template #link="{ content }">
<gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <div>
- <gl-button
- v-if="mr.newBlobPath"
- :href="mr.newBlobPath"
- category="primary"
- variant="confirm"
- data-testid="createFileButton"
- @click="onClickNewFile"
- >
- {{ __('Create file') }}
- </gl-button>
- </div>
</div>
</div>
</div>
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 f120680b440..52cdafd4717 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
@@ -97,7 +97,7 @@ export default {
return readyToMergeSubscription;
},
skip() {
- return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange;
+ return !this.mr?.id || this.loading;
},
variables() {
return {
@@ -146,6 +146,8 @@ export default {
AddedCommitMessage,
RelatedLinks,
HelpPopover,
+ AiCommitMessage: () =>
+ import('ee_component/vue_merge_request_widget/components/ai_commit_message.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -502,6 +504,10 @@ export default {
this.squashCommitMessage = val;
this.squashCommitMessageIsTouched = true;
},
+ appendCommitMessage(val) {
+ this.commitMessage = `${this.commitMessage}\n\n${val}`;
+ this.commitMessageIsTouched = true;
+ },
},
i18n: {
mergeCommitTemplateHintText: s__(
@@ -596,7 +602,15 @@ export default {
input-id="merge-message-edit"
class="gl-m-0! gl-p-0!"
@input="setCommitMessage"
- />
+ >
+ <template #header>
+ <ai-commit-message
+ v-if="mr.aiCommitMessageEnabled"
+ :id="mr.id"
+ @update="appendCommitMessage"
+ />
+ </template>
+ </commit-edit>
<li class="gl-m-0! gl-p-0!">
<p class="form-text text-muted">
<gl-sprintf :message="commitTemplateHintText">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index af036c01032..e4e81a5e2d1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -3,7 +3,6 @@ import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import StateContainer from '../state_container.vue';
const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.');
@@ -16,7 +15,6 @@ export default {
GlButton,
StateContainer,
},
- mixins: [glFeatureFlagsMixin()],
props: {
mr: {
type: Object,
@@ -52,16 +50,6 @@ export default {
>
{{ s__('mrWidget|Go to first unresolved thread') }}
</gl-button>
- <gl-button
- v-if="mr.createIssueToResolveDiscussionsPath && !glFeatures.hideCreateIssueResolveAll"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="js-create-issue gl-align-self-start gl-vertical-align-top"
- size="small"
- variant="confirm"
- category="secondary"
- >
- {{ s__('mrWidget|Resolve all with new issue') }}
- </gl-button>
</template>
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
index 5db5f1f8dcf..334fc01c9f7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -14,9 +14,7 @@ export default {
},
computed: {
widgets() {
- return [window.gon?.features?.refactorSecurityExtension && 'MrSecurityWidget'].filter(
- (w) => w,
- );
+ return ['MrSecurityWidget'];
},
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 18503720814..db237bc7439 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -182,6 +182,7 @@ export const INVALID_RULES_DOCS_PATH = helpPagePath(
);
export const DETAILED_MERGE_STATUS = {
+ PREPARING: 'PREPARING',
MERGEABLE: 'MERGEABLE',
CHECKING: 'CHECKING',
NOT_OPEN: 'NOT_OPEN',
diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js
index 5ca56074031..1b5929e31be 100644
--- a/app/assets/javascripts/vue_merge_request_widget/i18n.js
+++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js
@@ -1,5 +1,9 @@
import { __, s__ } from '~/locale';
+export const MR_WIDGET_PREPARING_ASYNCHRONOUSLY = s__(
+ 'mrWidget|Your merge request is almost ready!',
+);
+
export const MR_WIDGET_MISSING_BRANCH_WHICH = s__(
'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.',
);
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
index 3228c09c9b6..564e9321d54 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -39,7 +39,7 @@ export default {
};
},
skip() {
- return !this.mr?.id || !this.isRealtimeEnabled;
+ return !this.mr?.id;
},
updateQuery(
_,
@@ -63,14 +63,6 @@ export default {
disableCommittersApproval: false,
};
},
- computed: {
- isRealtimeEnabled() {
- // This mixin needs glFeatureFlagsMixin, but fatals if it's included here.
- // Parents that include this mixin (approvals) should also include the
- // glFeatureFlagsMixin mixin, or this will always be false.
- return Boolean(this.glFeatures?.realtimeApprovals);
- },
- },
methods: {
clearError() {
this.$emit('clearError');
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 6e0ee1cb912..af9e303594a 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
@@ -26,6 +26,7 @@ import ArchivedState from './components/states/mr_widget_archived.vue';
import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
+import PreparingState from './components/states/mr_widget_preparing.vue';
import ClosedState from './components/states/mr_widget_closed.vue';
import ConflictsState from './components/states/mr_widget_conflicts.vue';
import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
@@ -88,6 +89,7 @@ export default {
MrWidgetReadyToMerge,
ShaMismatch,
MrWidgetChecking: CheckingState,
+ MrWidgetPreparing: PreparingState,
MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState,
MrWidgetPipelineBlocked: PipelineBlockedState,
MrWidgetPipelineFailed: PipelineFailedState,
@@ -96,7 +98,6 @@ export default {
MrWidgetRebase: RebaseState,
SourceBranchRemovalStatus,
MrWidgetApprovals,
- SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'),
ReadyToMerge: ReadyToMergeState,
ReportWidgetContainer,
@@ -132,7 +133,7 @@ export default {
return getStateSubscription;
},
skip() {
- return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange;
+ return !this.mr?.id || this.loading;
},
variables() {
return {
@@ -199,7 +200,7 @@ export default {
);
},
shouldRenderApprovals() {
- return this.mr.state !== 'nothingToMerge';
+ return !['preparing', 'nothingToMerge'].includes(this.mr.state);
},
componentName() {
return stateToComponentMap[this.machineState] || classState[this.mr.state];
@@ -237,9 +238,6 @@ export default {
this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
);
},
- shouldRenderSecurityReport() {
- return Boolean(this.mr?.pipeline?.id);
- },
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
@@ -273,9 +271,6 @@ export default {
hasAlerts() {
return this.hasMergeError || this.showMergePipelineForkWarning;
},
- shouldShowSecurityExtension() {
- return window.gon?.features?.refactorSecurityExtension;
- },
shouldShowMergeDetails() {
if (this.mr.state === 'readyToMerge') return true;
@@ -599,15 +594,7 @@ export default {
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
<report-widget-container>
<extensions-container v-if="hasExtensions" :mr="mr" />
- <widget-container v-if="mr && shouldShowSecurityExtension" :mr="mr" />
- <security-reports-app
- v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension"
- :pipeline-id="mr.pipeline.id"
- :project-id="mr.sourceProjectId"
- :security-reports-docs-path="mr.securityReportsDocsPath"
- :target-project-full-path="mr.targetProjectFullPath"
- :mr-iid="mr.iid"
- />
+ <widget-container :mr="mr" />
</report-widget-container>
<div class="mr-section-container mr-widget-workflow">
<div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container">
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
index a6b35f20776..4366c01e0a2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
@@ -3,6 +3,7 @@ subscription getStateSubscription($issuableId: IssuableID!) {
... on MergeRequest {
id
detailedMergeStatus
+ commitCount
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index cead42b12ae..f90056a8e1a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -2,7 +2,9 @@ import { DETAILED_MERGE_STATUS } from '../constants';
import { stateKey } from './state_maps';
export default function deviseState() {
- if (!this.commitsCount) {
+ if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.PREPARING) {
+ return stateKey.preparing;
+ } else if (!this.commitsCount) {
return stateKey.nothingToMerge;
} else if (this.projectArchived) {
return stateKey.archived;
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 39ae1fda9f4..9ddf8241020 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,7 +1,7 @@
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { badgeState } from '~/issuable/components/status_box.vue';
import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants';
-import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
+import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetime_utility';
import { machine } from '~/lib/utils/finite_state_machine';
import {
MTWPS_MERGE_STRATEGY,
@@ -212,6 +212,7 @@ export default class MergeRequestStore {
setGraphqlSubscriptionData(data) {
this.detailedMergeStatus = data.detailedMergeStatus;
+ this.commitsCount = data.commitCount;
this.setState();
}
@@ -341,7 +342,7 @@ export default class MergeRequestStore {
return '';
}
- return format(date);
+ return format(date, timeagoLanguageCode);
}
static getPreferredAutoMergeStrategy(availableAutoMergeStrategies) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 9dfeaee905c..04468855942 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -10,6 +10,7 @@ export const stateToComponentMap = {
notAllowedToMerge: 'mr-widget-not-allowed',
archived: 'mr-widget-archived',
checking: 'mr-widget-checking',
+ preparing: 'mr-widget-preparing',
unresolvedDiscussions: 'mr-widget-unresolved-discussions',
pipelineBlocked: 'mr-widget-pipeline-blocked',
pipelineFailed: 'mr-widget-pipeline-failed',
@@ -38,6 +39,7 @@ export const stateKey = {
archived: 'archived',
missingBranch: 'missingBranch',
nothingToMerge: 'nothingToMerge',
+ preparing: 'preparing',
checking: 'checking',
conflicts: 'conflicts',
draft: 'draft',
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 175aef59ae5..c3f3226c46e 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -1,29 +1,25 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlButton,
- GlTooltip,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
},
props: {
- id: {
+ toggleText: {
type: String,
- required: false,
- default: '',
+ required: true,
},
actions: {
type: Array,
required: true,
},
- selectedKey: {
- type: String,
- required: false,
- default: '',
- },
category: {
type: String,
required: false,
@@ -34,78 +30,40 @@ export default {
required: false,
default: 'default',
},
- showActionTooltip: {
- type: Boolean,
- required: false,
- default: true,
- },
- },
- computed: {
- hasMultipleActions() {
- return this.actions.length > 1;
- },
- selectedAction() {
- return this.actions.find((x) => x.key === this.selectedKey) || this.actions[0];
- },
},
methods: {
handleItemClick(action) {
- this.$emit('select', action.key);
- },
- handleClick(action, evt) {
- this.$emit('actionClicked', { action });
- return action.handle?.(evt);
+ return action.handle?.();
},
},
};
</script>
<template>
- <span>
- <gl-dropdown
- v-if="hasMultipleActions"
- :id="id"
- :text="selectedAction.text"
- :split-href="selectedAction.href"
- :variant="variant"
- :category="category"
- split
- data-qa-selector="action_dropdown"
- @click="handleClick(selectedAction, $event)"
- >
- <template #button-content>
- <span class="gl-dropdown-button-text" v-bind="selectedAction.attrs">
- {{ selectedAction.text }}
- </span>
- </template>
- <template v-for="(action, index) in actions">
- <gl-dropdown-item
- :key="action.key"
- is-check-item
- :is-checked="action.key === selectedAction.key"
- :secondary-text="action.secondaryText"
- :data-qa-selector="`${action.key}_menu_item`"
- :data-testid="`action_${action.key}`"
- @click="handleItemClick(action)"
- >
- <span class="gl-font-weight-bold">{{ action.text }}</span>
- </gl-dropdown-item>
- <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" />
- </template>
- </gl-dropdown>
- <gl-button
- v-else-if="selectedAction"
- :id="id"
- v-bind="selectedAction.attrs"
- :variant="variant"
- :category="category"
- :href="selectedAction.href"
- @click="handleClick(selectedAction, $event)"
- >
- {{ selectedAction.text }}
- </gl-button>
- <gl-tooltip v-if="selectedAction.tooltip && showActionTooltip" :target="id">
- {{ selectedAction.tooltip }}
- </gl-tooltip>
- </span>
+ <gl-disclosure-dropdown
+ :variant="variant"
+ :category="category"
+ :toggle-text="toggleText"
+ data-qa-selector="action_dropdown"
+ >
+ <gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-item
+ v-for="action in actions"
+ :key="action.key"
+ v-bind="action.attrs"
+ :item="action"
+ :data-qa-selector="`${action.key}_menu_item`"
+ @action="handleItemClick(action)"
+ >
+ <template #list-item>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <span class="gl-font-weight-bold gl-mb-2">{{ action.text }}</span>
+ <span class="gl-text-gray-700">
+ {{ action.secondaryText }}
+ </span>
+ </div>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 7b5ded9348f..9023807eba3 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from './ci_icon.vue';
/**
* Renders CI Badge link with CI icon and status text based on
@@ -26,8 +26,8 @@ import CiIcon from './ci_icon.vue';
export default {
components: {
- GlLink,
CiIcon,
+ GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -42,6 +42,11 @@ export default {
required: false,
default: true,
},
+ badgeSize: {
+ type: String,
+ required: false,
+ default: 'md',
+ },
},
computed: {
title() {
@@ -51,27 +56,76 @@ export default {
// For now, this can either come from graphQL with camelCase or REST API in snake_case
return this.status.detailsPath || this.status.details_path;
},
- cssClass() {
- const className = this.status.group;
- return className ? `ci-status ci-${className}` : 'ci-status';
+ badgeStyles() {
+ switch (this.status.icon) {
+ case 'status_success':
+ return {
+ textColor: 'gl-text-green-700',
+ variant: 'success',
+ };
+ case 'status_warning':
+ return {
+ textColor: 'gl-text-orange-700',
+ variant: 'warning',
+ };
+ case 'status_failed':
+ return {
+ textColor: 'gl-text-red-700',
+ variant: 'danger',
+ };
+ case 'status_running':
+ return {
+ textColor: 'gl-text-blue-700',
+ variant: 'info',
+ };
+ case 'status_pending':
+ return {
+ textColor: 'gl-text-orange-700',
+ variant: 'warning',
+ };
+ case 'status_canceled':
+ return {
+ textColor: 'gl-text-gray-700',
+ variant: 'neutral',
+ };
+ case 'status_manual':
+ return {
+ textColor: 'gl-text-gray-700',
+ variant: 'neutral',
+ };
+ // default covers the styles for the remainder of CI
+ // statuses that are not explicitly stated here
+ default:
+ return {
+ textColor: 'gl-text-gray-600',
+ variant: 'muted',
+ };
+ }
},
},
};
</script>
<template>
- <gl-link
+ <gl-badge
v-gl-tooltip
- class="gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base"
- :class="cssClass"
:title="title"
- data-qa-selector="status_badge_link"
:href="detailsPath"
+ :size="badgeSize"
+ :variant="badgeStyles.variant"
+ :data-testid="`ci-badge-${status.text}`"
+ data-qa-selector="status_badge_link"
@click="$emit('ciStatusBadgeClick')"
>
<ci-icon :status="status" />
<template v-if="showText">
- <span class="gl-ml-2 gl-white-space-nowrap">{{ status.text }}</span>
+ <span
+ class="gl-ml-2 gl-white-space-nowrap"
+ :class="badgeStyles.textColor"
+ data-testid="ci-badge-text"
+ >
+ {{ status.text }}
+ </span>
</template>
- </gl-link>
+ </gl-badge>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
deleted file mode 100644
index dd6923d9fcd..00000000000
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownSectionHeader,
- GlFormInputGroup,
- GlButton,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { getHTTPProtocol } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownSectionHeader,
- GlFormInputGroup,
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- sshLink: {
- type: String,
- required: false,
- default: '',
- },
- httpLink: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- httpLabel() {
- const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : '';
- return sprintf(__('Clone with %{protocol}'), { protocol });
- },
- },
- labels: {
- defaultLabel: __('Clone'),
- ssh: __('Clone with SSH'),
- },
- copyURLTooltip: __('Copy URL'),
-};
-</script>
-<template>
- <gl-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="confirm">
- <div class="pb-2 mx-1">
- <template v-if="sshLink">
- <gl-dropdown-section-header>{{ $options.labels.ssh }}</gl-dropdown-section-header>
-
- <div class="mx-3">
- <gl-form-input-group :value="sshLink" readonly select-on-click>
- <template #append>
- <gl-button
- v-gl-tooltip.hover
- :title="$options.copyURLTooltip"
- :aria-label="$options.copyURLTooltip"
- :data-clipboard-text="sshLink"
- data-qa-selector="copy_ssh_url_button"
- icon="copy-to-clipboard"
- class="d-inline-flex"
- />
- </template>
- </gl-form-input-group>
- </div>
- </template>
-
- <template v-if="httpLink">
- <gl-dropdown-section-header>{{ httpLabel }}</gl-dropdown-section-header>
-
- <div class="mx-3">
- <gl-form-input-group :value="httpLink" readonly select-on-click>
- <template #append>
- <gl-button
- v-gl-tooltip.hover
- :title="$options.copyURLTooltip"
- :aria-label="$options.copyURLTooltip"
- :data-clipboard-text="httpLink"
- data-qa-selector="copy_http_url_button"
- icon="copy-to-clipboard"
- class="d-inline-flex"
- />
- </template>
- </gl-form-input-group>
- </div>
- </template>
- </div>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js
new file mode 100644
index 00000000000..ed0e9150bc4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.stories.js
@@ -0,0 +1,33 @@
+import CloneDropdown from './clone_dropdown.vue';
+
+export default {
+ component: CloneDropdown,
+ title: 'vue_shared/components/clone_dropdown',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CloneDropdown },
+ props: Object.keys(argTypes),
+ template: '<clone-dropdown v-bind="$props" />',
+});
+
+const sshLink = 'ssh://some-ssh-link';
+const httpLink = 'https://some-http-link';
+
+export const Default = Template.bind({});
+Default.args = {
+ sshLink,
+ httpLink,
+};
+
+export const HttpLink = Template.bind({});
+HttpLink.args = {
+ httpLink,
+ sshLink: '',
+};
+
+export const SSHLink = Template.bind({});
+SSHLink.args = {
+ sshLink,
+ httpLink: '',
+};
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue
new file mode 100644
index 00000000000..fa7c5bc1978
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui';
+import { getHTTPProtocol } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import CloneDropdownItem from './clone_dropdown_item.vue';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ CloneDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ sshLink: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ httpLink: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ httpLabel() {
+ const protocol = this.httpLink ? getHTTPProtocol(this.httpLink)?.toUpperCase() : '';
+ return sprintf(__('Clone with %{protocol}'), { protocol });
+ },
+ },
+ labels: {
+ defaultLabel: __('Clone'),
+ ssh: __('Clone with SSH'),
+ },
+};
+</script>
+<template>
+ <gl-disclosure-dropdown
+ :toggle-text="$options.labels.defaultLabel"
+ category="primary"
+ variant="confirm"
+ placement="right"
+ >
+ <clone-dropdown-item
+ v-if="sshLink"
+ :label="$options.labels.ssh"
+ :link="sshLink"
+ qa-selector="copy_ssh_url_button"
+ />
+ <clone-dropdown-item
+ v-if="httpLink"
+ :label="httpLabel"
+ :link="httpLink"
+ qa-selector="copy_http_url_button"
+ />
+ </gl-disclosure-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue
new file mode 100644
index 00000000000..0e322ebc686
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown/clone_dropdown_item.vue
@@ -0,0 +1,56 @@
+<script>
+import {
+ GlButton,
+ GlDisclosureDropdownItem,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlDisclosureDropdownItem,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ link: {
+ type: String,
+ required: true,
+ },
+ qaSelector: {
+ type: String,
+ required: true,
+ },
+ },
+ copyURLTooltip: __('Copy URL'),
+};
+</script>
+<template>
+ <gl-disclosure-dropdown-item>
+ <gl-form-group :label="label" class="gl-px-3 gl-mb-3">
+ <gl-form-input-group :value="link" readonly select-on-click>
+ <template #append>
+ <gl-button
+ v-gl-tooltip.hover
+ :title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
+ :data-clipboard-text="link"
+ :data-qa-selector="qaSelector"
+ icon="copy-to-clipboard"
+ class="gl-display-inline-flex"
+ />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+ </gl-disclosure-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
deleted file mode 100644
index 64e3b5d0bae..00000000000
--- a/app/assets/javascripts/vue_shared/components/confirm_fork_modal.vue
+++ /dev/null
@@ -1,68 +0,0 @@
-<script>
-import { GlModal } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export const i18n = {
- btnText: __('Fork project'),
- title: __('Fork project?'),
- message: __(
- 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
- ),
-};
-
-export default {
- name: 'ConfirmForkModal',
- components: {
- GlModal,
- },
- model: {
- prop: 'visible',
- event: 'change',
- },
- props: {
- visible: {
- type: Boolean,
- required: false,
- default: false,
- },
- modalId: {
- type: String,
- required: true,
- },
- forkPath: {
- type: String,
- required: true,
- },
- },
- computed: {
- btnActions() {
- return {
- cancel: { text: __('Cancel') },
- primary: {
- text: this.$options.i18n.btnText,
- attributes: {
- href: this.forkPath,
- variant: 'confirm',
- 'data-qa-selector': 'fork_project_button',
- 'data-method': 'post',
- },
- },
- };
- },
- },
- i18n,
-};
-</script>
-<template>
- <gl-modal
- :visible="visible"
- data-qa-selector="confirm_fork_modal"
- :modal-id="modalId"
- :title="$options.i18n.title"
- :action-primary="btnActions.primary"
- :action-cancel="btnActions.cancel"
- @change="$emit('change', $event)"
- >
- <p>{{ $options.i18n.message }}</p>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index faa50a50c69..3bb168e9051 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -131,7 +131,6 @@ export default {
ref="search"
:value="searchTerm"
:placeholder="searchText"
- class="js-dropdown-input-field"
@input="setSearchTerm"
/>
</slot>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 88062bf245f..c72356dc713 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -13,10 +13,11 @@ import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searche
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import { createAlert } from '~/alert';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import { SORT_DIRECTION } from './constants';
-import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils';
+import { filterEmptySearchTerm, uniqueTokens } from './filtered_search_utils';
export default {
components: {
@@ -338,7 +339,7 @@ export default {
</script>
<template>
- <div class="vue-filtered-search-bar-container gl-md-display-flex">
+ <div class="vue-filtered-search-bar-container gl-md-display-flex gl-min-w-0">
<gl-form-checkbox
v-if="showCheckbox"
class="gl-align-self-center"
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 5cc96471aef..65c783ada55 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
@@ -5,15 +5,6 @@ import { queryToObject } from '~/lib/utils/url_utility';
import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants';
/**
- * Strips enclosing quotations from a string if it has one.
- *
- * @param {String} value String to strip quotes from
- *
- * @returns {String} String without any enclosure
- */
-export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2');
-
-/**
* This method removes duplicate tokens from tokens array.
*
* @param {Array} tokens Array of tokens as defined by `GlFilteredSearch`
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 b5783265ffa..5a7382bcd7c 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
@@ -9,12 +9,9 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants';
-import {
- getRecentlyUsedSuggestions,
- setTokenValueToRecentlyUsed,
- stripQuotes,
-} from '../filtered_search_utils';
+import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
export default {
components: {
@@ -113,13 +110,15 @@ export default {
* present in "Recently used"
*/
availableSuggestions() {
- return this.searchKey
+ const suggestions = this.searchKey
? this.suggestions
: this.suggestions.filter(
(tokenValue) =>
!this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) &&
!this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]),
);
+
+ return this.applyMaxSuggestions(suggestions);
},
showDefaultSuggestions() {
return this.availableDefaultSuggestions.length > 0;
@@ -196,6 +195,12 @@ export default {
setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue);
}
},
+ applyMaxSuggestions(suggestions) {
+ const { maxSuggestions } = this.config;
+ if (!maxSuggestions || maxSuggestions <= 0) return suggestions;
+
+ return suggestions.slice(0, maxSuggestions);
+ },
},
};
</script>
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 c69a2927ec9..0ce784fab1a 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
@@ -3,8 +3,8 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { OPTIONS_NONE_ANY } from '../constants';
-import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
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 6a7dd6131e2..3dfdb15db31 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
@@ -3,10 +3,10 @@ import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import { OPTIONS_NONE_ANY } from '../constants';
-import { stripQuotes } from '../filtered_search_utils';
import BaseToken from './base_token.vue';
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 81b8a6c78fc..8322fe92de4 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
@@ -4,8 +4,8 @@ import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { stripQuotes } from '~/lib/utils/text_utility';
import { DEFAULT_MILESTONES } from '../constants';
-import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
index 897ca2f84d2..186f5619b87 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -71,7 +71,6 @@ export default {
}
},
},
- popperOptions: { strategy: 'fixed' },
};
</script>
@@ -89,7 +88,7 @@ export default {
searchable
size="small"
class="comment-template-dropdown"
- :popper-options="$options.popperOptions"
+ positioning-strategy="fixed"
:searching="$apollo.queries.savedReplies.loading"
@shown="fetchCommentTemplates"
@search="setCommentTemplateSearch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 8802f364665..af0b34f1389 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -199,7 +199,7 @@ export default {
insertIntoTextarea(text) {
const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
if (textArea) {
- const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated using AI')}_`;
+ const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated by AI')}_`;
updateText({
textArea,
tag: generatedByText,
@@ -267,6 +267,7 @@ export default {
:css-classes="['diff-suggest-popover']"
placement="bottom"
:show="suggestPopoverVisible"
+ triggers=""
>
<strong>{{ __('New! Suggest changes directly') }}</strong>
<p class="mb-2">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index d9d4056e997..9fd606d775d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -95,6 +95,11 @@ export default {
required: false,
default: false,
},
+ disableAttachments: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -111,6 +116,9 @@ export default {
// Match textarea focus behavior
return this.autofocus && !this.autofocused ? 'end' : false;
},
+ markdownFieldRestrictedToolBarItems() {
+ return this.disableAttachments ? ['attach-file'] : [];
+ },
},
watch: {
value(val) {
@@ -231,7 +239,7 @@ export default {
v-bind="$attrs"
data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
- can-attach-file
+ :can-attach-file="!disableAttachments"
:textarea-value="markdown"
:uploads-path="uploadsPath"
:enable-autocomplete="enableAutocomplete"
@@ -240,6 +248,7 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:show-content-editor-switcher="enableContentEditor"
:drawio-enabled="drawioEnabled"
+ :restricted-tool-bar-items="markdownFieldRestrictedToolBarItems"
:remove-border="true"
@enableContentEditor="onEditingModeChange('contentEditor')"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
@@ -256,8 +265,7 @@ export default {
:disabled="disabled"
@input="updateMarkdownFromMarkdownField"
@keydown="$emit('keydown', $event)"
- >
- </textarea>
+ ></textarea>
</template>
</markdown-field>
<div v-else>
@@ -273,6 +281,7 @@ export default {
:enable-autocomplete="enableAutocomplete"
:autocomplete-data-sources="autocompleteDataSources"
:editable="!disabled"
+ :disable-attachments="disableAttachments"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
index ac4f06a665d..8ff14220eab 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants';
import MarkdownEditor from './markdown_editor.vue';
import eventHub from './eventhub';
@@ -67,6 +68,9 @@ export function mountMarkdownEditor() {
newIssuePath,
} = el.dataset;
+ const supportsQuickActions = parseBoolean(el.dataset.supportsQuickActions ?? true);
+ const enableAutocomplete = parseBoolean(el.dataset.enableAutocomplete ?? true);
+ const disableAttachments = parseBoolean(el.dataset.disableAttachments ?? false);
const hiddenInput = el.querySelector('input[type="hidden"]');
const formFieldName = hiddenInput.getAttribute('name');
const formFieldId = hiddenInput.getAttribute('id');
@@ -102,9 +106,11 @@ export function mountMarkdownEditor() {
'data-qa-selector': qaSelector,
},
autosaveKey,
- enableAutocomplete: true,
+ enableAutocomplete,
autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
- supportsQuickActions: true,
+ supportsQuickActions,
+ disableAttachments,
+ autofocus: true,
},
});
},
diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue
index 379f22fdc6f..4bb32a53b30 100644
--- a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue
@@ -4,6 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__ } from '~/locale';
import { contentTop } from '~/lib/utils/common_utils';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getRenderedMarkdown } from './utils/fetch';
export const cache = {};
@@ -95,10 +96,17 @@ export default {
safeHtmlConfig: {
ADD_TAGS: ['copy-code'],
},
+ DRAWER_Z_INDEX,
};
</script>
<template>
- <gl-drawer :header-height="drawerTop" :open="open" header-sticky @close="closeDrawer">
+ <gl-drawer
+ :header-height="drawerTop"
+ :open="open"
+ header-sticky
+ :z-index="$options.DRAWER_Z_INDEX"
+ @close="closeDrawer"
+ >
<template #title>
<h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
new file mode 100644
index 00000000000..064458cfc1f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/mr_more_dropdown.vue
@@ -0,0 +1,361 @@
+<script>
+import {
+ GlLoadingIcon,
+ GlButton,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { __, s__ } from '~/locale';
+import api from '~/api';
+import axios from '~/lib/utils/axios_utils';
+import { createAlert } from '~/alert';
+import MergeRequest from '~/merge_request';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
+
+Vue.use(VueApollo);
+
+export default {
+ apolloProvider,
+ i18n: {
+ edit: __('Edit'),
+ copyReferenceText: __('Copy reference'),
+ errorMessage: __('Something went wrong. Please try again.'),
+ issuableName: __('merge request'),
+ reportAbuse: __('Report abuse'),
+ markAsReady: __('Mark as ready'),
+ markAsDraft: __('Mark as draft'),
+ close: __('Close %{issuableType}'),
+ closing: __('Closing %{issuableType}...'),
+ reopen: __('Reopen %{issuableType}'),
+ reopening: __('Reopening %{issuableType}...'),
+ lock: __('Lock %{issuableType}'),
+ mergeRequestActions: __('Merge request actions'),
+ },
+ components: {
+ GlLoadingIcon,
+ GlButton,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
+ SidebarSubscriptionsWidget,
+ AbuseCategorySelector,
+ NewHeaderActionsPopover,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ editUrl: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ default: false,
+ required: true,
+ },
+ isLoggedIn: {
+ type: Boolean,
+ defauilt: false,
+ required: false,
+ },
+ canUpdateMergeRequest: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ open: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isMerged: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ sourceProjectMissing: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ clipboardText: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ reportedUserId: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
+ reportedFromUrl: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ },
+ data() {
+ return {
+ isOpen: this.open,
+ draft: this.mr.draft,
+ issuableType: TYPE_MERGE_REQUEST,
+ fullPath: this.projectPath,
+ isLoading: false,
+ isLoadingDraft: false,
+ isLoadingClipboard: false,
+ isReportAbuseDrawerOpen: false,
+ };
+ },
+ computed: {
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ draftLabel() {
+ return this.draft ? this.$options.i18n.markAsReady : this.$options.i18n.markAsDraft;
+ },
+ draftState() {
+ return this.draft ? 'ready' : 'draft';
+ },
+ editItem() {
+ return {
+ text: this.$options.i18n.edit,
+ href: this.editUrl,
+ };
+ },
+ },
+ methods: {
+ draftAction() {
+ this.isLoadingDraft = true;
+
+ axios
+ .put(`?merge_request[wip_event]=${this.draftState}`, null, {
+ params: { format: 'json' },
+ })
+ .then(({ data }) => {
+ MergeRequest.toggleDraftStatus(data.title, this.draft);
+ })
+ .catch(() => {
+ createAlert({
+ message: this.$options.i18n.errorMessage,
+ });
+ })
+ .finally(() => {
+ this.draft = !this.draft;
+ this.isLoadingDraft = false;
+ this.closeActionsDropdown();
+ });
+ },
+ stateAction(state) {
+ this.isLoading = true;
+
+ api
+ .updateMergeRequest(this.mr.target_project_id, this.mr.iid, { state_event: state })
+ .then(() => {
+ window.location.reload();
+ })
+ .catch(() => {
+ createAlert({
+ message: this.$options.i18n.errorMessage,
+ });
+ })
+ .finally(() => {
+ this.isOpen = !this.isOpen;
+ this.isLoading = false;
+ this.closeActionsDropdown();
+ });
+ },
+ copyClipboardAction() {
+ this.$toast.show(s__('MergeRequests|Reference copied'));
+ this.closeActionsDropdown();
+ },
+ reportAbuseAction(isOpen) {
+ if (isOpen) {
+ this.closeActionsDropdown();
+ }
+
+ this.isReportAbuseDrawerOpen = isOpen;
+ },
+ closeActionsDropdown() {
+ this.$refs.mrMoreActionsDropdown.close();
+ },
+ showReopenMergeRequestOption() {
+ return !this.sourceProjectMissing && !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-w-full gl-relative"
+ data-testid="merge-request-actions"
+ >
+ <gl-disclosure-dropdown
+ id="new-actions-header-dropdown"
+ ref="mrMoreActionsDropdown"
+ data-testid="dropdown-toggle"
+ placement="right"
+ :auto-close="false"
+ >
+ <template #toggle>
+ <div class="gl-min-h-7 gl-mb-2 gl-md-mb-0!" :aria-label="$options.i18n.mergeRequestActions">
+ <gl-button
+ class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full"
+ category="secondary"
+ >
+ <span class="">{{ $options.i18n.mergeRequestActions }}</span>
+ <gl-icon class="dropdown-chevron" name="chevron-down" />
+ </gl-button>
+ <gl-button
+ class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret gl-ml-3"
+ category="tertiary"
+ icon="ellipsis_v"
+ />
+ </div>
+ </template>
+ <gl-disclosure-dropdown-group v-if="isLoggedIn && isMovedMrSidebar">
+ <sidebar-subscriptions-widget
+ :iid="String(mr.iid)"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ data-testid="notification-toggle"
+ />
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group
+ bordered
+ :class="{ 'gl-mt-0! gl-pt-0! gl-border-t-0!': !(isLoggedIn && isMovedMrSidebar) }"
+ >
+ <gl-disclosure-dropdown-item
+ v-if="canUpdateMergeRequest"
+ class="gl-md-display-none!"
+ data-testid="edit-merge-request"
+ :item="editItem"
+ />
+
+ <gl-disclosure-dropdown-item
+ v-if="isOpen && canUpdateMergeRequest"
+ data-testid="ready-and-draft-action"
+ @action="draftAction"
+ >
+ <template #list-item>
+ <gl-loading-icon v-if="isLoadingDraft" inline size="sm" />
+ {{ draftLabel }}
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item
+ v-if="isOpen && canUpdateMergeRequest"
+ data-testid="close-merge-request"
+ @action="stateAction('close')"
+ >
+ <template #list-item>
+ <template v-if="isLoading">
+ <gl-loading-icon inline size="sm" />
+ {{
+ sprintf($options.i18n.closing, {
+ issuableType: $options.i18n.issuableName,
+ })
+ }}
+ </template>
+ <template v-else>
+ {{ sprintf($options.i18n.close, { issuableType: $options.i18n.issuableName }) }}
+ </template>
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item
+ v-else-if="!isMerged && showReopenMergeRequestOption && canUpdateMergeRequest"
+ data-testid="reopen-merge-request"
+ @action="stateAction('reopen')"
+ >
+ <template #list-item>
+ <template v-if="isLoading">
+ <gl-loading-icon inline size="sm" />
+ {{
+ sprintf($options.i18n.reopening, {
+ issuableType: $options.i18n.issuableName,
+ })
+ }}
+ </template>
+ <template v-else>
+ {{ sprintf($options.i18n.reopen, { issuableType: $options.i18n.issuableName }) }}
+ </template>
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item v-if="isMovedMrSidebar" class="js-sidebar-lock-root">
+ <template #list-item>
+ {{ sprintf($options.i18n.lock, { issuableType: $options.i18n.issuableName }) }}
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item
+ v-if="isMovedMrSidebar"
+ class="js-copy-reference"
+ :data-clipboard-text="clipboardText"
+ data-testid="copy-reference"
+ @action="copyClipboardAction"
+ >
+ <template #list-item>
+ {{ $options.i18n.copyReferenceText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group
+ v-if="!isCurrentUser"
+ bordered
+ :class="{ 'gl-mt-0! gl-pt-0! gl-border-t-0!': !canUpdateMergeRequest }"
+ >
+ <gl-disclosure-dropdown-item
+ class="js-report-abuse-dropdown-item"
+ data-testid="report-abuse-option"
+ @action="reportAbuseAction(true)"
+ >
+ <template #list-item>
+ {{ $options.i18n.reportAbuse }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
+
+ <new-header-actions-popover v-if="isMovedMrSidebar" :issue-type="issuableType" />
+
+ <abuse-category-selector
+ v-if="!isCurrentUser && isReportAbuseDrawerOpen"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedFromUrl"
+ :show-drawer="isReportAbuseDrawerOpen"
+ @close-drawer="reportAbuseAction(false)"
+ />
+ </div>
+</template>
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 748d6082abd..57b19620c10 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -45,18 +45,31 @@ export default {
required: false,
default: false,
},
+ internalNote: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['getUserData']),
renderedNote() {
return renderMarkdown(this.note.body);
},
+ internalNoteClass() {
+ return {
+ 'internal-note': this.internalNote,
+ };
+ },
},
};
</script>
<template>
- <timeline-entry-item class="note note-wrapper note-comment being-posted fade-in-half">
+ <timeline-entry-item
+ class="note note-wrapper note-comment being-posted fade-in-half"
+ :class="internalNoteClass"
+ >
<div class="timeline-avatar gl-float-left">
<gl-avatar-link :href="getUserData.path">
<gl-avatar
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index d77061d4b31..28a16cd846a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -1,53 +1,64 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import { getPageParamValue, getPageSearchString } from '~/blob/utils';
+import LineHighlighter from '~/blob/line_highlighter';
+import ChunkLine from './chunk_line.vue';
/*
* We only highlight the chunk that is currently visible to the user.
* By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
*
- * Content that is not visible to the user (i.e. not highlighted) does not need to look nice,
- * so by rendering raw (non-highlighted) text, the browser spends less resources on painting
- * content that is not immediately relevant.
- * Why use plaintext as opposed to hiding content entirely?
- * If content is hidden entirely, native find text (⌘ + F) won't work.
+ * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
+ * so by making text transparent and rendering raw (non-highlighted) text,
+ * the browser spends less resources on painting content that is not immediately relevant.
+ *
+ * Why use transparent text as opposed to hiding content entirely?
+ * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
+ * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
*/
export default {
components: {
+ ChunkLine,
GlIntersectionObserver,
},
- directives: {
- SafeHtml,
- },
props: {
- isHighlighted: {
+ isFirstChunk: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
chunkIndex: {
type: Number,
required: false,
default: 0,
},
- rawContent: {
- type: String,
+ isHighlighted: {
+ type: Boolean,
required: true,
},
- highlightedContent: {
+ content: {
type: String,
required: true,
},
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
totalLines: {
type: Number,
required: false,
default: 0,
},
- startingFrom: {
+ totalChunks: {
type: Number,
required: false,
default: 0,
},
+ language: {
+ type: String,
+ required: false,
+ default: null,
+ },
blamePath: {
type: String,
required: true,
@@ -55,36 +66,37 @@ export default {
},
data() {
return {
- hasAppeared: false,
isLoading: true,
};
},
computed: {
- shouldHighlight() {
- return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted);
- },
lines() {
return this.content.split('\n');
},
- pageSearchString() {
- const page = getPageParamValue(this.number);
- return getPageSearchString(this.blamePath, page);
- },
},
+
created() {
- if (this.chunkIndex === 0) {
- // Display first chunk ASAP in order to improve perceived performance
+ if (this.isFirstChunk) {
this.isLoading = false;
return;
}
- window.requestIdleCallback(() => {
+ window.requestIdleCallback(async () => {
this.isLoading = false;
+ const { hash } = this.$route;
+ if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) {
+ // when the last chunk is loaded scroll to the hash
+ await this.$nextTick();
+ const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ lineHighlighter.highlightHash(hash);
+ }
});
},
methods: {
handleChunkAppear() {
- this.hasAppeared = true;
+ if (!this.isHighlighted) {
+ this.$emit('appear', this.chunkIndex);
+ }
},
calculateLineNumber(index) {
return this.startingFrom + index + 1;
@@ -94,36 +106,28 @@ export default {
</script>
<template>
<gl-intersection-observer @appear="handleChunkAppear">
- <div class="gl-display-flex">
- <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column">
- <div
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="calculateLineNumber(index)"
+ :content="line"
+ :language="language"
+ :blame-path="blamePath"
+ />
+ </div>
+ <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent">
+ <div class="gl-display-flex gl-flex-direction-column content-visibility-auto">
+ <span
v-for="(n, index) in totalLines"
+ v-once
+ :id="`L${calculateLineNumber(index)}`"
:key="index"
- data-testid="line-numbers"
- class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
- >
- <a
- class="gl-user-select-none gl-shadow-none! file-line-blame"
- :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
- ></a>
- <a
- :id="`L${calculateLineNumber(index)}`"
- class="gl-user-select-none gl-shadow-none! file-line-num"
- :href="`#L${calculateLineNumber(index)}`"
- :data-line-number="calculateLineNumber(index)"
- >
- {{ calculateLineNumber(index) }}
- </a>
- </div>
+ data-testid="line-number"
+ v-text="calculateLineNumber(index)"
+ ></span>
</div>
-
- <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent">
- <!-- Placeholder for line numbers while content is not highlighted -->
- </div>
-
- <pre
- class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0"
- ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre>
+ <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div>
</div>
</gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue
deleted file mode 100644
index 28a16cd846a..00000000000
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue
+++ /dev/null
@@ -1,133 +0,0 @@
-<script>
-import { GlIntersectionObserver } from '@gitlab/ui';
-import LineHighlighter from '~/blob/line_highlighter';
-import ChunkLine from './chunk_line.vue';
-
-/*
- * We only highlight the chunk that is currently visible to the user.
- * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
- *
- * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
- * so by making text transparent and rendering raw (non-highlighted) text,
- * the browser spends less resources on painting content that is not immediately relevant.
- *
- * Why use transparent text as opposed to hiding content entirely?
- * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
- * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
- */
-export default {
- components: {
- ChunkLine,
- GlIntersectionObserver,
- },
- props: {
- isFirstChunk: {
- type: Boolean,
- required: false,
- default: false,
- },
- chunkIndex: {
- type: Number,
- required: false,
- default: 0,
- },
- isHighlighted: {
- type: Boolean,
- required: true,
- },
- content: {
- type: String,
- required: true,
- },
- startingFrom: {
- type: Number,
- required: false,
- default: 0,
- },
- totalLines: {
- type: Number,
- required: false,
- default: 0,
- },
- totalChunks: {
- type: Number,
- required: false,
- default: 0,
- },
- language: {
- type: String,
- required: false,
- default: null,
- },
- blamePath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isLoading: true,
- };
- },
- computed: {
- lines() {
- return this.content.split('\n');
- },
- },
-
- created() {
- if (this.isFirstChunk) {
- this.isLoading = false;
- return;
- }
-
- window.requestIdleCallback(async () => {
- this.isLoading = false;
- const { hash } = this.$route;
- if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) {
- // when the last chunk is loaded scroll to the hash
- await this.$nextTick();
- const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
- lineHighlighter.highlightHash(hash);
- }
- });
- },
- methods: {
- handleChunkAppear() {
- if (!this.isHighlighted) {
- this.$emit('appear', this.chunkIndex);
- }
- },
- calculateLineNumber(index) {
- return this.startingFrom + index + 1;
- },
- },
-};
-</script>
-<template>
- <gl-intersection-observer @appear="handleChunkAppear">
- <div v-if="isHighlighted">
- <chunk-line
- v-for="(line, index) in lines"
- :key="index"
- :number="calculateLineNumber(index)"
- :content="line"
- :language="language"
- :blame-path="blamePath"
- />
- </div>
- <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent">
- <div class="gl-display-flex gl-flex-direction-column content-visibility-auto">
- <span
- v-for="(n, index) in totalLines"
- v-once
- :id="`L${calculateLineNumber(index)}`"
- :key="index"
- data-testid="line-number"
- v-text="calculateLineNumber(index)"
- ></span>
- </div>
- <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div>
- </div>
- </gl-intersection-observer>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue
new file mode 100644
index 00000000000..d77061d4b31
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue
@@ -0,0 +1,129 @@
+<script>
+import { GlIntersectionObserver } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { getPageParamValue, getPageSearchString } from '~/blob/utils';
+
+/*
+ * We only highlight the chunk that is currently visible to the user.
+ * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
+ *
+ * Content that is not visible to the user (i.e. not highlighted) does not need to look nice,
+ * so by rendering raw (non-highlighted) text, the browser spends less resources on painting
+ * content that is not immediately relevant.
+ * Why use plaintext as opposed to hiding content entirely?
+ * If content is hidden entirely, native find text (⌘ + F) won't work.
+ */
+export default {
+ components: {
+ GlIntersectionObserver,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ chunkIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ rawContent: {
+ type: String,
+ required: true,
+ },
+ highlightedContent: {
+ type: String,
+ required: true,
+ },
+ totalLines: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ blamePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ hasAppeared: false,
+ isLoading: true,
+ };
+ },
+ computed: {
+ shouldHighlight() {
+ return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted);
+ },
+ lines() {
+ return this.content.split('\n');
+ },
+ pageSearchString() {
+ const page = getPageParamValue(this.number);
+ return getPageSearchString(this.blamePath, page);
+ },
+ },
+ created() {
+ if (this.chunkIndex === 0) {
+ // Display first chunk ASAP in order to improve perceived performance
+ this.isLoading = false;
+ return;
+ }
+
+ window.requestIdleCallback(() => {
+ this.isLoading = false;
+ });
+ },
+ methods: {
+ handleChunkAppear() {
+ this.hasAppeared = true;
+ },
+ calculateLineNumber(index) {
+ return this.startingFrom + index + 1;
+ },
+ },
+};
+</script>
+<template>
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div class="gl-display-flex">
+ <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column">
+ <div
+ v-for="(n, index) in totalLines"
+ :key="index"
+ data-testid="line-numbers"
+ class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
+ >
+ <a
+ class="gl-user-select-none gl-shadow-none! file-line-blame"
+ :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
+ ></a>
+ <a
+ :id="`L${calculateLineNumber(index)}`"
+ class="gl-user-select-none gl-shadow-none! file-line-num"
+ :href="`#L${calculateLineNumber(index)}`"
+ :data-line-number="calculateLineNumber(index)"
+ >
+ {{ calculateLineNumber(index) }}
+ </a>
+ </div>
+ </div>
+
+ <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent">
+ <!-- Placeholder for line numbers while content is not highlighted -->
+ </div>
+
+ <pre
+ class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0"
+ ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 58db1ceda95..6c49a601401 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -147,3 +147,7 @@ export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unico
* HAML: https://github.com/highlightjs/highlight.js/issues/3783
* */
export const LEGACY_FALLBACKS = ['python', 'haml'];
+
+export const CODEOWNERS_FILE_NAME = 'CODEOWNERS';
+
+export const CODEOWNERS_LANGUAGE = 'codeowners';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js b/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js
new file mode 100644
index 00000000000..33149b42222
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/languages/codeowners.js
@@ -0,0 +1,42 @@
+/*
+Language: Codeowners
+Description: language definition for CODEOWNERS files
+*/
+
+export default (hljs) => {
+ return {
+ name: 'codeowners',
+ case_insensitive: true,
+ contains: [
+ {
+ scope: 'number',
+ begin: '\\[\\d+\\]',
+ end: '(?=\\s|$)',
+ },
+ {
+ scope: 'regexp',
+ begin: '^\\^|\\*',
+ },
+ {
+ scope: 'attr',
+ begin: '^\\s*(?![#^*[])\\S|(?<=\\*)\\S*',
+ end: '(?=\\s|$)',
+ contains: [
+ {
+ scope: 'regexp',
+ begin: '\\*',
+ },
+ ],
+ },
+ {
+ scope: 'keyword',
+ begin: '\\[(?!\\d+\\])[^\\]]+\\]',
+ },
+ {
+ scope: 'variable',
+ begin: '\\S*@.*$',
+ },
+ hljs.HASH_COMMENT_MODE,
+ ],
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
index 3540ac6caf1..a79e88a1132 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_child_nodes.js
@@ -11,25 +11,25 @@ import { escape } from 'lodash';
const newlineRegex = /\r?\n/;
const generateClassName = (suffix) => (suffix ? `hljs-${escape(suffix)}` : '');
const generateCloseTag = (includeClose) => (includeClose ? '</span>' : '');
-const generateHLJSTag = (kind, content = '', includeClose) =>
- `<span class="${generateClassName(kind)}">${escape(content)}${generateCloseTag(includeClose)}`;
+const generateHLJSTag = (scope, content = '', includeClose) =>
+ `<span class="${generateClassName(scope)}">${escape(content)}${generateCloseTag(includeClose)}`;
-const format = (node, kind = '') => {
+const format = (node, scope = '') => {
let buffer = '';
if (typeof node === 'string') {
buffer += node
.split(newlineRegex)
- .map((newline) => generateHLJSTag(kind, newline, true))
+ .map((newline) => generateHLJSTag(scope, newline, true))
.join('\n');
- } else if (node.kind || node.sublanguage) {
+ } else if (node.scope || node.sublanguage) {
const { children } = node;
if (children.length && children.length === 1) {
- buffer += format(children[0], node.kind);
+ buffer += format(children[0], node.scope);
} else {
- buffer += generateHLJSTag(node.kind);
+ buffer += generateHLJSTag(node.scope);
children.forEach((subChild) => {
- buffer += format(subChild, node.kind);
+ buffer += format(subChild, node.scope);
});
buffer += `</span>`;
}
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 11708b6f1f6..9dc6dc1b93a 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,40 +1,201 @@
<script>
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import Tracking from '~/tracking';
+import { GlLoadingIcon } from '@gitlab/ui';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
+import languageLoader from '~/content_editor/services/highlight_js_language_loader';
import addBlobLinksTracking from '~/blob/blob_links_tracking';
-import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants';
+import Tracking from '~/tracking';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+ LINES_PER_CHUNK,
+ LEGACY_FALLBACKS,
+ CODEOWNERS_FILE_NAME,
+ CODEOWNERS_LANGUAGE,
+} from './constants';
import Chunk from './components/chunk.vue';
+import { registerPlugins } from './plugins/index';
+/*
+ * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
+ * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
+ *
+ * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
+ * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
+ * it does not trigger a repaint on a parent element that wraps all 1000 lines.
+ */
export default {
+ name: 'SourceViewer',
components: {
+ GlLoadingIcon,
Chunk,
},
- directives: {
- SafeHtml,
- },
mixins: [Tracking.mixin()],
- inject: {
- highlightWorker: { default: null },
- },
props: {
blob: {
type: Object,
required: true,
},
- chunks: {
- type: Array,
- required: false,
- default: () => [],
+ },
+ data() {
+ return {
+ languageDefinition: null,
+ content: this.blob.rawTextBlob,
+ hljs: null,
+ firstChunk: null,
+ chunks: {},
+ isLoading: true,
+ isLineSelected: false,
+ lineHighlighter: null,
+ };
+ },
+ computed: {
+ splitContent() {
+ return this.content.split(/\r?\n/);
+ },
+ language() {
+ return this.blob.name === this.$options.codeownersFileName
+ ? this.$options.codeownersLanguage
+ : ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()];
+ },
+ lineNumbers() {
+ return this.splitContent.length;
+ },
+ unsupportedLanguage() {
+ const supportedLanguages = Object.keys(languageLoader);
+ const unsupportedLanguage =
+ !supportedLanguages.includes(this.language) &&
+ !supportedLanguages.includes(this.blob.language?.toLowerCase());
+
+ return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage;
+ },
+ totalChunks() {
+ return Object.keys(this.chunks).length;
},
},
- created() {
- this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language });
+ async created() {
addBlobLinksTracking();
+ this.trackEvent(EVENT_LABEL_VIEWER);
+
+ if (this.unsupportedLanguage) {
+ this.handleUnsupportedLanguage();
+ return;
+ }
+
+ this.generateFirstChunk();
+ this.hljs = await this.loadHighlightJS();
+
+ if (this.language) {
+ this.languageDefinition = await this.loadLanguage();
+ }
+
+ // Highlight the first chunk as soon as highlight.js is available
+ this.highlightChunk(null, true);
+
+ window.requestIdleCallback(async () => {
+ // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
+ this.generateRemainingChunks();
+ this.isLoading = false;
+ await this.$nextTick();
+ this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ });
+ },
+ methods: {
+ trackEvent(label) {
+ this.track(EVENT_ACTION, { label, property: this.blob.language });
+ },
+ handleUnsupportedLanguage() {
+ this.trackEvent(EVENT_LABEL_FALLBACK);
+ this.$emit('error');
+ },
+ generateFirstChunk() {
+ const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
+ this.firstChunk = this.createChunk(lines);
+ },
+ generateRemainingChunks() {
+ const result = {};
+ for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
+ }
+
+ this.chunks = result;
+ },
+ createChunk(lines, startingFrom = 0) {
+ return {
+ content: lines.join('\n'),
+ startingFrom,
+ totalLines: lines.length,
+ language: this.language,
+ isHighlighted: false,
+ };
+ },
+ highlightChunk(index, isFirstChunk) {
+ const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
+
+ if (chunk.isHighlighted) {
+ return;
+ }
+
+ const { highlightedContent, language } = this.highlight(chunk.content, this.language);
+
+ Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
+
+ this.selectLine();
+
+ this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
+ },
+ highlight(content, language) {
+ let detectedLanguage = language;
+ let highlightedContent;
+ if (this.hljs) {
+ registerPlugins(this.hljs, this.blob.fileType, this.content);
+ if (!detectedLanguage) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(content);
+ highlightedContent = hljsHighlightAuto.value;
+ detectedLanguage = hljsHighlightAuto.language;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
+ }
+ }
+
+ return { highlightedContent, language: detectedLanguage };
+ },
+ loadHighlightJS() {
+ // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
+ return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ },
+ async loadLanguage() {
+ let languageDefinition;
+
+ try {
+ languageDefinition = await languageLoader[this.language]();
+ this.hljs.registerLanguage(this.language, languageDefinition.default);
+ } catch (message) {
+ this.$emit('error', message);
+ }
+
+ return languageDefinition;
+ },
+ async selectLine() {
+ if (this.isLineSelected || !this.lineHighlighter) {
+ return;
+ }
+
+ this.isLineSelected = true;
+ await this.$nextTick();
+ this.lineHighlighter.highlightHash(this.$route.hash);
+ },
},
userColorScheme: window.gon.user_color_scheme,
+ currentlySelectedLine: null,
+ codeownersFileName: CODEOWNERS_FILE_NAME,
+ codeownersLanguage: CODEOWNERS_LANGUAGE,
};
</script>
-
<template>
<div
class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
@@ -44,15 +205,32 @@ export default {
data-qa-selector="blob_viewer_file_content"
>
<chunk
- v-for="(chunk, _, index) in chunks"
- :key="index"
- :chunk-index="index"
- :is-highlighted="Boolean(chunk.isHighlighted)"
- :raw-content="chunk.rawContent"
- :highlighted-content="chunk.highlightedContent"
+ v-if="firstChunk"
+ :lines="firstChunk.lines"
+ :total-lines="firstChunk.totalLines"
+ :content="firstChunk.content"
+ :starting-from="firstChunk.startingFrom"
+ :is-highlighted="firstChunk.isHighlighted"
+ is-first-chunk
+ :language="firstChunk.language"
+ :blame-path="blob.blamePath"
+ />
+
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
+ <chunk
+ v-for="(chunk, key, index) in chunks"
+ v-else
+ :key="key"
+ :lines="chunk.lines"
+ :content="chunk.content"
:total-lines="chunk.totalLines"
:starting-from="chunk.startingFrom"
+ :is-highlighted="chunk.isHighlighted"
+ :chunk-index="index"
+ :language="chunk.language"
:blame-path="blob.blamePath"
+ :total-chunks="totalChunks"
+ @appear="highlightChunk"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue
deleted file mode 100644
index 26cf45c7570..00000000000
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue
+++ /dev/null
@@ -1,227 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import LineHighlighter from '~/blob/line_highlighter';
-import eventHub from '~/notes/event_hub';
-import languageLoader from '~/content_editor/services/highlight_js_language_loader';
-import addBlobLinksTracking from '~/blob/blob_links_tracking';
-import Tracking from '~/tracking';
-import {
- EVENT_ACTION,
- EVENT_LABEL_VIEWER,
- EVENT_LABEL_FALLBACK,
- ROUGE_TO_HLJS_LANGUAGE_MAP,
- LINES_PER_CHUNK,
- LEGACY_FALLBACKS,
-} from './constants';
-import Chunk from './components/chunk_deprecated.vue';
-import { registerPlugins } from './plugins/index';
-
-/*
- * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
- * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
- *
- * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
- * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
- * it does not trigger a repaint on a parent element that wraps all 1000 lines.
- */
-export default {
- components: {
- GlLoadingIcon,
- Chunk,
- },
- mixins: [Tracking.mixin()],
- props: {
- blob: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- languageDefinition: null,
- content: this.blob.rawTextBlob,
- language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()],
- hljs: null,
- firstChunk: null,
- chunks: {},
- isLoading: true,
- isLineSelected: false,
- lineHighlighter: null,
- };
- },
- computed: {
- splitContent() {
- return this.content.split(/\r?\n/);
- },
- lineNumbers() {
- return this.splitContent.length;
- },
- unsupportedLanguage() {
- const supportedLanguages = Object.keys(languageLoader);
- const unsupportedLanguage =
- !supportedLanguages.includes(this.language) &&
- !supportedLanguages.includes(this.blob.language?.toLowerCase());
-
- return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage;
- },
- totalChunks() {
- return Object.keys(this.chunks).length;
- },
- },
- async created() {
- addBlobLinksTracking();
- this.trackEvent(EVENT_LABEL_VIEWER);
-
- if (this.unsupportedLanguage) {
- this.handleUnsupportedLanguage();
- return;
- }
-
- this.generateFirstChunk();
- this.hljs = await this.loadHighlightJS();
-
- if (this.language) {
- this.languageDefinition = await this.loadLanguage();
- }
-
- // Highlight the first chunk as soon as highlight.js is available
- this.highlightChunk(null, true);
-
- window.requestIdleCallback(async () => {
- // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
- this.generateRemainingChunks();
- this.isLoading = false;
- await this.$nextTick();
- this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
- });
- },
- methods: {
- trackEvent(label) {
- this.track(EVENT_ACTION, { label, property: this.blob.language });
- },
- handleUnsupportedLanguage() {
- this.trackEvent(EVENT_LABEL_FALLBACK);
- this.$emit('error');
- },
- generateFirstChunk() {
- const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
- this.firstChunk = this.createChunk(lines);
- },
- generateRemainingChunks() {
- const result = {};
- for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
- const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
- const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
- result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
- }
-
- this.chunks = result;
- },
- createChunk(lines, startingFrom = 0) {
- return {
- content: lines.join('\n'),
- startingFrom,
- totalLines: lines.length,
- language: this.language,
- isHighlighted: false,
- };
- },
- highlightChunk(index, isFirstChunk) {
- const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
-
- if (chunk.isHighlighted) {
- return;
- }
-
- const { highlightedContent, language } = this.highlight(chunk.content, this.language);
-
- Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
-
- this.selectLine();
-
- this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
- },
- highlight(content, language) {
- let detectedLanguage = language;
- let highlightedContent;
- if (this.hljs) {
- registerPlugins(this.hljs, this.blob.fileType, this.content);
- if (!detectedLanguage) {
- const hljsHighlightAuto = this.hljs.highlightAuto(content);
- highlightedContent = hljsHighlightAuto.value;
- detectedLanguage = hljsHighlightAuto.language;
- } else if (this.languageDefinition) {
- highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
- }
- }
-
- return { highlightedContent, language: detectedLanguage };
- },
- loadHighlightJS() {
- // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
- return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
- },
- async loadLanguage() {
- let languageDefinition;
-
- try {
- languageDefinition = await languageLoader[this.language]();
- this.hljs.registerLanguage(this.language, languageDefinition.default);
- } catch (message) {
- this.$emit('error', message);
- }
-
- return languageDefinition;
- },
- async selectLine() {
- if (this.isLineSelected || !this.lineHighlighter) {
- return;
- }
-
- this.isLineSelected = true;
- await this.$nextTick();
- this.lineHighlighter.highlightHash(this.$route.hash);
- },
- },
- userColorScheme: window.gon.user_color_scheme,
- currentlySelectedLine: null,
-};
-</script>
-<template>
- <div
- class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
- :class="$options.userColorScheme"
- data-type="simple"
- :data-path="blob.path"
- data-qa-selector="blob_viewer_file_content"
- >
- <chunk
- v-if="firstChunk"
- :lines="firstChunk.lines"
- :total-lines="firstChunk.totalLines"
- :content="firstChunk.content"
- :starting-from="firstChunk.startingFrom"
- :is-highlighted="firstChunk.isHighlighted"
- is-first-chunk
- :language="firstChunk.language"
- :blame-path="blob.blamePath"
- />
-
- <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
- <chunk
- v-for="(chunk, key, index) in chunks"
- v-else
- :key="key"
- :lines="chunk.lines"
- :content="chunk.content"
- :total-lines="chunk.totalLines"
- :starting-from="chunk.startingFrom"
- :is-highlighted="chunk.isHighlighted"
- :chunk-index="index"
- :language="chunk.language"
- :blame-path="blob.blamePath"
- :total-chunks="totalChunks"
- @appear="highlightChunk"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
new file mode 100644
index 00000000000..7e18c8414d5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue
@@ -0,0 +1,64 @@
+<script>
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import Tracking from '~/tracking';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants';
+import Chunk from './components/chunk_new.vue';
+
+/*
+ * Note, this is a new experimental version of the SourceViewer, it is not ready for production use.
+ * See the following issue for more details: https://gitlab.com/gitlab-org/gitlab/-/issues/391586
+ */
+
+export default {
+ name: 'SourceViewerNew',
+ components: {
+ Chunk,
+ },
+ directives: {
+ SafeHtml,
+ },
+ mixins: [Tracking.mixin()],
+ inject: {
+ highlightWorker: { default: null },
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ chunks: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ created() {
+ this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language });
+ addBlobLinksTracking();
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+
+<template>
+ <div
+ class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
+ :class="$options.userColorScheme"
+ data-type="simple"
+ :data-path="blob.path"
+ data-qa-selector="blob_viewer_file_content"
+ >
+ <chunk
+ v-for="(chunk, _, index) in chunks"
+ :key="index"
+ :chunk-index="index"
+ :is-highlighted="Boolean(chunk.isHighlighted)"
+ :raw-content="chunk.rawContent"
+ :highlighted-content="chunk.highlightedContent"
+ :total-lines="chunk.totalLines"
+ :starting-from="chunk.startingFrom"
+ :blame-path="blob.blamePath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
index 247f49c1345..6764ad4ce73 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown/timezone_dropdown.vue
@@ -1,20 +1,19 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
-import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { formatTimezone } from '~/lib/utils/datetime_utility';
export default {
name: 'TimezoneDropdown',
components: {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- },
- directives: {
- autofocusonshow,
+ GlCollapsibleListbox,
},
props: {
+ headerText: {
+ type: String,
+ required: false,
+ default: '',
+ },
value: {
type: String,
required: true,
@@ -52,11 +51,10 @@ export default {
identifier: timezone.identifier,
}));
},
- filteredResults() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.timezones.filter((timezone) =>
- timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm),
- );
+ filteredListboxItems() {
+ return this.timezones
+ .filter((timezone) => timezone.formattedTimezone.toLowerCase().includes(this.searchTerm))
+ .map(({ formattedTimezone }) => ({ value: formattedTimezone, text: formattedTimezone }));
},
selectedTimezoneLabel() {
return this.tzValue || __('Select timezone');
@@ -68,14 +66,14 @@ export default {
},
},
methods: {
- selectTimezone(selectedTimezone) {
- this.tzValue = selectedTimezone.formattedTimezone;
+ selectTimezone(formattedTimezone) {
+ const selectedTimezone = this.timezones.find(
+ (timezone) => timezone.formattedTimezone === formattedTimezone,
+ );
+ this.tzValue = formattedTimezone;
this.$emit('input', selectedTimezone);
this.searchTerm = '';
},
- isSelected(timezone) {
- return this.tzValue === timezone.formattedTimezone;
- },
initialTimezone(timezones, value) {
if (!value) {
return undefined;
@@ -89,6 +87,9 @@ export default {
return undefined;
},
+ setSearchTerm(value) {
+ this.searchTerm = value?.toLowerCase();
+ },
},
};
</script>
@@ -101,31 +102,17 @@ export default {
:value="timezoneIdentifier || value"
type="hidden"
/>
- <gl-dropdown
- :text="selectedTimezoneLabel"
- :class="additionalClass"
+ <gl-collapsible-listbox
+ :header-text="headerText"
+ :items="filteredListboxItems"
+ :toggle-text="selectedTimezoneLabel"
+ :toggle-class="additionalClass"
+ :no-results-text="$options.translations.noResultsText"
+ :selected="tzValue"
block
- lazy
- menu-class="gl-w-full!"
- v-bind="$attrs"
- >
- <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
- <gl-dropdown-item
- v-for="timezone in filteredResults"
- :key="timezone.formattedTimezone"
- :is-checked="isSelected(timezone)"
- is-check-item
- @click="selectTimezone(timezone)"
- >
- {{ timezone.formattedTimezone }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="!filteredResults.length"
- class="gl-pointer-events-none"
- data-testid="noMatchingResults"
- >
- {{ $options.translations.noResultsText }}
- </gl-dropdown-item>
- </gl-dropdown>
+ searchable
+ @search="setSearchTerm"
+ @select="selectTimezone"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
deleted file mode 100644
index c3b43d40adf..00000000000
--- a/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { __ } from '~/locale';
-
-export const SHOW_MORE = __('Show more');
-export const SHOW_LESS = __('Show less');
-export const STATES = {
- INITIAL: 'initial',
- TRUNCATED: 'truncated',
- EXTENDED: 'extended',
-};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
deleted file mode 100644
index 6a7ac72c31e..00000000000
--- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { escape } from 'lodash';
-import TruncatedText from './truncated_text.vue';
-
-export default {
- component: TruncatedText,
- title: 'vue_shared/truncated_text',
-};
-
-const Template = (args, { argTypes }) => ({
- components: { TruncatedText },
- props: Object.keys(argTypes),
- template: `
- <truncated-text v-bind="$props">
- <template v-if="${'default' in args}" v-slot>
- <span style="white-space: pre-line;">${escape(args.default)}</span>
- </template>
- </truncated-text>
- `,
-});
-
-export const Default = Template.bind({});
-Default.args = {
- lines: 3,
- mobileLines: 10,
- default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'),
-};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
deleted file mode 100644
index 96fc04ec825..00000000000
--- a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-import { GlResizeObserverDirective, GlButton } from '@gitlab/ui';
-import { STATES, SHOW_MORE, SHOW_LESS } from './constants';
-
-export default {
- name: 'TruncatedText',
- components: {
- GlButton,
- },
- directives: {
- GlResizeObserver: GlResizeObserverDirective,
- },
- props: {
- lines: {
- type: Number,
- required: false,
- default: 3,
- },
- mobileLines: {
- type: Number,
- required: false,
- default: 10,
- },
- },
- data() {
- return {
- state: STATES.INITIAL,
- };
- },
- computed: {
- showTruncationToggle() {
- return this.state !== STATES.INITIAL;
- },
- truncationToggleText() {
- if (this.state === STATES.TRUNCATED) {
- return SHOW_MORE;
- }
- return SHOW_LESS;
- },
- styleObject() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return { '--lines': this.lines, '--mobile-lines': this.mobileLines };
- },
- isTruncated() {
- return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden';
- },
- },
- methods: {
- onResize({ target }) {
- if (target.scrollHeight > target.offsetHeight) {
- this.state = STATES.TRUNCATED;
- } else if (this.state === STATES.TRUNCATED) {
- this.state = STATES.INITIAL;
- }
- },
- toggleTruncation() {
- if (this.state === STATES.TRUNCATED) {
- this.state = STATES.EXTENDED;
- } else if (this.state === STATES.EXTENDED) {
- this.state = STATES.TRUNCATED;
- }
- },
- },
-};
-</script>
-
-<template>
- <section>
- <article
- ref="content"
- v-gl-resize-observer="onResize"
- :class="isTruncated"
- :style="styleObject"
- >
- <slot></slot>
- </article>
- <gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{
- truncationToggleText
- }}</gl-button>
- </section>
-</template>
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 abd3575d020..4879baced0d 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
@@ -308,7 +308,7 @@ export default {
<gl-search-box-by-type
ref="search"
:value="search"
- class="js-dropdown-input-field"
+ data-testid="user-search-input"
@input="debouncedSearchKeyUpdate"
/>
</template>
@@ -345,7 +345,7 @@ export default {
data-testid="selected-participant"
@click.native.capture.stop="unselect(item.username)"
>
- <sidebar-participant :user="item" :issuable-type="issuableType" />
+ <sidebar-participant :user="item" :issuable-type="issuableType" selected />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
diff --git a/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue
new file mode 100644
index 00000000000..b4afb27c497
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/web_ide/confirm_fork_modal.vue
@@ -0,0 +1,114 @@
+<script>
+import { GlModal, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+import getWritableForksQuery from './get_writable_forks.query.graphql';
+
+export const i18n = {
+ btnText: __('Create a new fork'),
+ title: __('Fork project?'),
+ message: __('You can’t edit files directly in this project.'),
+ existingForksMessage: __(
+ 'To submit your changes in a merge request, switch to one of these forks or create a new fork.',
+ ),
+ newForkMessage: __('To submit your changes in a merge request, create a new fork.'),
+};
+
+export default {
+ name: 'ConfirmForkModal',
+ components: {
+ GlModal,
+ GlLoadingIcon,
+ GlLink,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ },
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ forkPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ forks: [],
+ };
+ },
+ apollo: {
+ forks: {
+ query: getWritableForksQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update({ project } = {}) {
+ return project?.visibleForks?.nodes.map((node) => {
+ return {
+ text: node.fullPath,
+ href: node.webUrl,
+ };
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.forks.loading;
+ },
+ hasWritableForks() {
+ return this.forks.length;
+ },
+ btnActions() {
+ return {
+ cancel: { text: __('Cancel') },
+ primary: {
+ text: this.$options.i18n.btnText,
+ attributes: {
+ href: this.forkPath,
+ variant: 'confirm',
+ 'data-qa-selector': 'fork_project_button',
+ },
+ },
+ };
+ },
+ },
+ i18n,
+};
+</script>
+<template>
+ <gl-modal
+ :visible="visible"
+ data-qa-selector="confirm_fork_modal"
+ :modal-id="modalId"
+ :title="$options.i18n.title"
+ :action-primary="btnActions.primary"
+ :action-cancel="btnActions.cancel"
+ @change="$emit('change', $event)"
+ >
+ <p>{{ $options.i18n.message }}</p>
+ <gl-loading-icon v-if="isLoading" />
+ <template v-else-if="hasWritableForks">
+ <p>{{ $options.i18n.existingForksMessage }}</p>
+ <div v-for="fork in forks" :key="fork.text">
+ <gl-link :href="fork.href">{{ fork.text }}</gl-link>
+ </div>
+ </template>
+ <p v-else>{{ $options.i18n.newForkMessage }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql b/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql
new file mode 100644
index 00000000000..044b79e64f3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/web_ide/get_writable_forks.query.graphql
@@ -0,0 +1,12 @@
+query getWritableForks($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ visibleForks(minimumAccessLevel: DEVELOPER) {
+ nodes {
+ id
+ fullPath
+ webUrl
+ }
+ }
+ }
+}
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 3c08142e2b9..82f4edcbd5f 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -3,9 +3,7 @@ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants';
export const i18n = {
@@ -21,22 +19,18 @@ export const i18n = {
webIdeTooltip: s__(
'WebIDE|Quickly and easily edit multiple files in your project. Press . to open',
),
+ toggleText: __('Edit'),
};
-export const PREFERRED_EDITOR_KEY = 'gl-web-ide-button-selected';
-export const PREFERRED_EDITOR_RESET_KEY = 'gl-web-ide-button-selected-reset';
-
export default {
components: {
ActionsButton,
- LocalStorageSync,
GlModal,
GlSprintf,
GlLink,
ConfirmForkModal,
},
i18n,
- mixins: [glFeatureFlagsMixin()],
props: {
isFork: {
type: Boolean,
@@ -141,7 +135,6 @@ export default {
},
data() {
return {
- selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE,
showEnableGitpodModal: false,
showForkModal: false,
};
@@ -155,10 +148,11 @@ export default {
this.gitpodAction,
].filter((action) => action);
},
+ hasActions() {
+ return this.actions.length > 0;
+ },
editAction() {
- if (!this.showEditButton) {
- return null;
- }
+ if (!this.showEditButton) return null;
const handleOptions = this.needsToFork
? {
@@ -176,9 +170,8 @@ export default {
return {
key: KEY_EDIT,
- text: __('Edit'),
+ text: __('Edit single file'),
secondaryText: __('Edit this file only.'),
- tooltip: '',
attrs: {
'data-qa-selector': 'edit_button',
'data-track-action': 'click_consolidated_edit',
@@ -199,13 +192,10 @@ export default {
return __('Web IDE');
},
webIdeAction() {
- if (!this.showWebIdeButton) {
- return null;
- }
+ if (!this.showWebIdeButton) return null;
const handleOptions = this.needsToFork
? {
- href: '#modal-confirm-fork-webide',
handle: () => {
if (this.disableForkModal) {
this.$emit('edit', 'ide');
@@ -216,9 +206,7 @@ export default {
},
}
: {
- href: this.webIdeUrl,
- handle: (evt) => {
- evt.preventDefault();
+ handle: () => {
visitUrl(this.webIdeUrl, true);
},
};
@@ -227,7 +215,6 @@ export default {
key: KEY_WEB_IDE,
text: this.webIdeActionText,
secondaryText: this.$options.i18n.webIdeText,
- tooltip: this.$options.i18n.webIdeTooltip,
attrs: {
'data-qa-selector': 'web_ide_button',
'data-track-action': 'click_consolidated_edit_ide',
@@ -258,7 +245,6 @@ export default {
key: KEY_PIPELINE_EDITOR,
text: __('Edit in pipeline editor'),
secondaryText,
- tooltip: secondaryText,
attrs: {
'data-qa-selector': 'pipeline_editor_button',
},
@@ -283,7 +269,6 @@ export default {
key: KEY_GITPOD,
text: this.gitpodActionText,
secondaryText,
- tooltip: secondaryText,
attrs: {
'data-qa-selector': 'gitpod_button',
},
@@ -309,53 +294,31 @@ export default {
},
};
},
- },
- mounted() {
- this.resetPreferredEditor();
+ mountForkModal() {
+ const { disableForkModal, showWebIdeButton, showEditButton } = this;
+ if (disableForkModal) return false;
+
+ return showWebIdeButton || showEditButton;
+ },
},
methods: {
- select(key) {
- this.selection = key;
- },
showModal(dataKey) {
this[dataKey] = true;
},
- resetPreferredEditor() {
- if (!this.glFeatures.vscodeWebIde || this.showEditButton) {
- return;
- }
-
- if (localStorage.getItem(PREFERRED_EDITOR_RESET_KEY) === 'true') {
- return;
- }
-
- localStorage.setItem(PREFERRED_EDITOR_KEY, KEY_WEB_IDE);
- localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, true);
-
- this.select(KEY_WEB_IDE);
- },
},
webIdeButtonId: 'web-ide-link',
- PREFERRED_EDITOR_KEY,
};
</script>
<template>
<div class="gl-sm-ml-3">
<actions-button
+ v-if="hasActions"
:id="$options.webIdeButtonId"
:actions="actions"
- :selected-key="selection"
+ :toggle-text="$options.i18n.toggleText"
:variant="isBlob ? 'confirm' : 'default'"
:category="isBlob ? 'primary' : 'secondary'"
- show-action-tooltip
- @select="select"
- />
- <local-storage-sync
- :storage-key="$options.PREFERRED_EDITOR_KEY"
- :value="selection"
- as-string
- @input="select"
/>
<gl-modal
v-if="computedShowGitpodButton && !gitpodEnabled"
@@ -369,7 +332,7 @@ export default {
</gl-sprintf>
</gl-modal>
<confirm-fork-modal
- v-if="showWebIdeButton || showEditButton"
+ v-if="mountForkModal"
v-model="showForkModal"
:modal-id="forkModalId"
:fork-path="forkPath"
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue
new file mode 100644
index 00000000000..0ada1d8a6ae
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_grid.vue
@@ -0,0 +1 @@
+<template><div></div></template>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index 62a32d8942a..ce33d7a9b4b 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -4,7 +4,6 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUS_CLOSED } from '~/issues/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
@@ -91,7 +90,7 @@ export default {
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
},
createdAt() {
- return getTimeago().format(this.issuable.createdAt);
+ return this.timeFormatted(this.issuable.createdAt);
},
timestamp() {
if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
@@ -102,11 +101,11 @@ export default {
formattedTimestamp() {
if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return sprintf(__('closed %{timeago}'), {
- timeago: getTimeago().format(this.issuable.closedAt),
+ timeago: this.timeFormatted(this.issuable.closedAt),
});
} else if (this.issuable.updatedAt !== this.issuable.createdAt) {
return sprintf(__('updated %{timeAgo}'), {
- timeAgo: getTimeago().format(this.issuable.updatedAt),
+ timeAgo: this.timeFormatted(this.issuable.updatedAt),
});
}
return undefined;
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 95108933a0b..4023337a1cb 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -6,12 +6,14 @@ import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import issuableEventHub from '~/issues/list/eventhub';
import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
+import IssuableGrid from './issuable_grid.vue';
const VueDraggable = () => import('vuedraggable');
@@ -30,12 +32,14 @@ export default {
IssuableTabs,
FilteredSearchBar,
IssuableItem,
+ IssuableGrid,
IssuableBulkEditSidebar,
GlPagination,
VueDraggable,
PageSizeSelector,
LocalStorageSync,
},
+ mixins: [glFeatureFlagMixin()],
props: {
namespace: {
type: String,
@@ -194,6 +198,11 @@ export default {
required: false,
default: false,
},
+ isGridView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -229,6 +238,9 @@ export default {
issuablesWrapper() {
return this.isManualOrdering ? VueDraggable : 'ul';
},
+ gridViewFeatureEnabled() {
+ return Boolean(this.glFeatures?.issuesGridView);
+ },
},
watch: {
issuables(list) {
@@ -342,7 +354,7 @@ export default {
<template v-else>
<component
:is="issuablesWrapper"
- v-if="issuables.length > 0"
+ v-if="issuables.length > 0 && !isGridView"
class="content-list issuable-list issues-list"
:class="{ 'manual-ordering': isManualOrdering }"
v-bind="$options.vueDraggableAttributes"
@@ -382,6 +394,9 @@ export default {
</template>
</issuable-item>
</component>
+ <div v-else-if="issuables.length > 0 && isGridView">
+ <issuable-grid />
+ </div>
<slot v-else name="empty-state"></slot>
</template>
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 c8c7deff882..5ab2e346a7a 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
@@ -43,6 +43,11 @@ export default {
type: String,
required: true,
},
+ isSaas: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
@@ -81,11 +86,7 @@ export default {
},
showNewTopLevelGroupAlert() {
- if (this.activePanel.detailProps === undefined) {
- return false;
- }
-
- return this.activePanel.detailProps.parentGroupName === '';
+ return this.isSaas && this.activePanel.detailProps?.parentGroupName === '';
},
showSuperSidebarToggle() {
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 472bc1dfacc..dd5d4edda59 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -2,6 +2,7 @@
import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
import Feature from './feature.vue';
import SkeletonLoader from './skeleton_loader.vue';
@@ -22,11 +23,15 @@ export default {
props: {
versionDigest: {
type: String,
- required: true,
+ required: false,
+ default: undefined,
},
},
computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
+ getDrawerHeaderHeight() {
+ return getContentWrapperHeight();
+ },
},
mounted() {
this.openDrawer(this.versionDigest);
@@ -68,6 +73,7 @@ export default {
ref="drawer"
v-gl-resize-observer="handleResize"
class="whats-new-drawer gl-reset-line-height"
+ :header-height="getDrawerHeaderHeight"
:z-index="700"
:open="open"
@close="closeDrawer"
@@ -75,7 +81,7 @@ export default {
<template #title>
<h4 class="page-title gl-my-2">{{ __("What's new") }}</h4>
</template>
- <template v-if="features.length">
+ <template v-if="features.length || !fetching">
<gl-infinite-scroll
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index f9b725ed429..1621c4d5f27 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -9,7 +9,7 @@ export const setNotification = (appEl) => {
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
- if (localStorage.getItem(STORAGE_KEY) === versionDigest) {
+ if (localStorage.getItem(STORAGE_KEY) === versionDigest || versionDigest === undefined) {
notificationEl.classList.remove('with-notifications');
if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl);
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue
index f8dfa1c7f01..1fa217f456e 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -19,17 +19,13 @@ import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/
import $ from 'jquery';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
-import axios from '~/lib/utils/axios_utils';
+import descriptionVersionHistoryMixin from 'ee_else_ce/work_items/mixins/description_version_history';
import { getLocationHash } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import NoteHeader from '~/notes/components/note_header.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
-
export default {
i18n: {
deleteButtonLabel: __('Remove description history'),
@@ -46,7 +42,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
- mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()],
+ mixins: [descriptionVersionHistoryMixin],
props: {
note: {
type: Object,
@@ -60,15 +56,13 @@ export default {
showLines: false,
loadingDiff: false,
isLoadingDescriptionVersion: false,
+ descriptionVersions: {},
};
},
computed: {
targetNoteHash() {
return getLocationHash();
},
- descriptionVersions() {
- return [];
- },
noteAnchorId() {
return `note_${this.noteId}`;
},
@@ -78,42 +72,22 @@ export default {
toggleIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
- // following 2 methods taken from code in `collapseLongCommitList` of notes.js:
actionTextHtml() {
return $(this.note.bodyHtml).unwrap().html();
},
- hasMoreCommits() {
- return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT;
- },
- descriptionVersion() {
- return this.descriptionVersions[this.note.description_version_id];
+ descriptionVersionId() {
+ return getIdFromGraphQLId(this.systemNoteDescriptionVersion?.id);
},
noteId() {
return getIdFromGraphQLId(this.note.id);
},
+ descriptionVersion() {
+ return this.descriptionVersions[this.descriptionVersionId];
+ },
},
mounted() {
renderGFM(this.$refs['gfm-content']);
},
- methods: {
- fetchDescriptionVersion() {},
- softDeleteDescriptionVersion() {},
-
- async toggleDiff() {
- this.showLines = !this.showLines;
-
- if (!this.lines.length) {
- this.loadingDiff = true;
- const { data } = await axios.get(this.note.outdated_line_change_path);
-
- this.lines = data.map((l) => ({
- ...l,
- rich_text: l.rich_text.replace(/^[+ -]/, ''),
- }));
- this.loadingDiff = false;
- }
- },
- },
safeHtmlConfig: {
ADD_TAGS: ['use'], // to support icon SVGs
},
@@ -141,10 +115,7 @@ export default {
:is-system-note="true"
>
<span ref="gfm-content" v-safe-html="actionTextHtml"></span>
- <template
- v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
- #extra-controls
- >
+ <template v-if="canSeeDescriptionVersion" #extra-controls>
&middot;
<gl-button
v-if="canSeeDescriptionVersion"
@@ -155,36 +126,20 @@ export default {
@click="toggleDescriptionVersion"
>{{ __('Compare with previous version') }}</gl-button
>
- <gl-button
- v-if="note.outdated_line_change_path"
- :icon="showLines ? 'chevron-up' : 'chevron-down'"
- variant="link"
- data-testid="outdated-lines-change-btn"
- class="gl-vertical-align-text-bottom gl-font-sm!"
- @click="toggleDiff"
- >
- {{ __('Compare changes') }}
- </gl-button>
</template>
</note-header>
</div>
<div class="note-body">
- <div
- v-safe-html="note.bodyHtml"
- :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }"
- class="note-text md"
- ></div>
- <div v-if="hasMoreCommits" class="flex-list">
- <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
- <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" />
- <span>{{ __('Toggle commit list') }}</span>
- </div>
- </div>
- <div v-if="shouldShowDescriptionVersion" class="description-version pt-2">
+ <div v-if="shouldShowDescriptionVersion" class="description-version gl-pt-3! gl-pl-4">
<pre v-if="isLoadingDescriptionVersion" class="loading-state">
<gl-skeleton-loader />
</pre>
- <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre>
+ <pre
+ v-else
+ v-safe-html="descriptionVersion"
+ data-testid="description-version-diff"
+ class="wrapper gl-mt-3"
+ ></pre>
<gl-button
v-if="displayDeleteButton"
v-gl-tooltip
@@ -198,39 +153,6 @@ export default {
@click="deleteDescriptionVersion"
/>
</div>
- <div
- v-if="lines.length && showLines"
- class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden"
- >
- <table
- :class="$options.userColorSchemeClass"
- class="code js-syntax-highlight"
- data-testid="outdated-lines"
- >
- <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder">
- <td
- :class="line.type"
- class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!"
- >
- {{ line.old_line }}
- </td>
- <td
- :class="line.type"
- class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!"
- >
- {{ line.new_line }}
- </td>
- <td
- :class="line.type"
- class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!"
- v-html="line.rich_text /* eslint-disable-line vue/no-v-html */"
- ></td>
- </tr>
- </table>
- </div>
- <div v-else-if="showLines" class="mt-4">
- <gl-skeleton-loader />
- </div>
</div>
</div>
</timeline-entry-item>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index e10a82b5197..c330eccb186 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -69,6 +69,11 @@ export default {
required: false,
default: false,
},
+ isInternalThread: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -132,8 +137,8 @@ export default {
isProjectArchived() {
return this.workItem?.project?.archived;
},
- canUpdate() {
- return this.workItem?.userPermissions?.updateWorkItem;
+ canCreateNote() {
+ return this.workItem?.userPermissions?.createNote;
},
workItemState() {
return this.workItem?.state;
@@ -147,7 +152,8 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this
.isNewDiscussion,
- 'gl-bg-white! gl-pt-0!': this.isEditing,
+ 'gl-pt-0! is-replying': this.isEditing,
+ 'internal-note': this.isInternalThread,
};
},
},
@@ -162,7 +168,7 @@ export default {
},
},
methods: {
- async updateWorkItem(commentText) {
+ async updateWorkItem({ commentText, isNoteInternal = false }) {
this.isSubmitting = true;
this.$emit('replying', commentText);
try {
@@ -175,6 +181,7 @@ export default {
noteableId: this.workItemId,
body: commentText,
discussionId: this.discussionId || null,
+ internal: isNoteInternal,
},
},
update(store, createNoteData) {
@@ -236,7 +243,7 @@ export default {
<li :class="timelineEntryClass">
<work-item-note-signed-out v-if="!signedIn" />
<work-item-comment-locked
- v-else-if="!canUpdate"
+ v-else-if="!canCreateNote"
:work-item-type="workItemType"
:is-project-archived="isProjectArchived"
/>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index cea28b30d42..c317ec48732 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __, sprintf } from '~/locale';
@@ -19,12 +19,24 @@ import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue
import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
export default {
+ i18n: {
+ internal: s__('Notes|Make this an internal note'),
+ internalVisibility: s__(
+ 'Notes|Internal notes are only visible to members with the role of Reporter or higher',
+ ),
+ addInternalNote: __('Add internal note'),
+ },
constantOptions: {
markdownDocsPath: helpPagePath('user/markdown'),
},
components: {
GlButton,
MarkdownEditor,
+ GlFormCheckbox,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
inject: ['fullPath'],
@@ -89,6 +101,7 @@ export default {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
updateInProgress: false,
+ isNoteInternal: false,
};
},
computed: {
@@ -118,6 +131,9 @@ export default {
cancelButtonText() {
return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel');
},
+ commentButtonTextComputed() {
+ return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText;
+ },
},
methods: {
setCommentText(newText) {
@@ -213,18 +229,33 @@ export default {
supports-quick-actions
:autofocus="autofocus"
@input="setCommentText"
- @keydown.meta.enter="$emit('submitForm', commentText)"
- @keydown.ctrl.enter="$emit('submitForm', commentText)"
+ @keydown.meta.enter="$emit('submitForm', { commentText, isNoteInternal })"
+ @keydown.ctrl.enter="$emit('submitForm', { commentText, isNoteInternal })"
@keydown.esc.stop="cancelEditing"
/>
+ <gl-form-checkbox
+ v-if="isNewDiscussion"
+ v-model="isNoteInternal"
+ class="gl-mb-2"
+ data-testid="internal-note-checkbox"
+ >
+ {{ $options.i18n.internal }}
+ <gl-icon
+ v-gl-tooltip:tooltipcontainer.bottom
+ name="question-o"
+ :size="16"
+ :title="$options.i18n.internalVisibility"
+ class="gl-text-blue-500"
+ />
+ </gl-form-checkbox>
<gl-button
category="primary"
variant="confirm"
data-testid="confirm-button"
:disabled="!commentText.length"
:loading="isSubmitting"
- @click="$emit('submitForm', commentText)"
- >{{ commentButtonText }}
+ @click="$emit('submitForm', { commentText, isNoteInternal })"
+ >{{ commentButtonTextComputed }}
</gl-button>
<gl-button
data-testid="cancel-button"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
index e98e03f76fd..f030363664f 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -164,12 +164,7 @@ export default {
@reportAbuse="$emit('reportAbuse', note)"
@error="$emit('error', $event)"
/>
- <timeline-entry-item
- v-else
- :class="{ 'internal-note': note.internal }"
- :data-note-id="noteId"
- class="note note-discussion gl-px-0"
- >
+ <timeline-entry-item v-else :data-note-id="noteId" class="note note-discussion gl-px-0">
<div class="timeline-content">
<div class="discussion">
<div class="discussion-body">
@@ -222,7 +217,11 @@ export default {
@error="$emit('error', $event)"
/>
</template>
- <work-item-note-replying v-if="isReplying" :body="replyingText" />
+ <work-item-note-replying
+ v-if="isReplying"
+ :is-internal-note="note.internal"
+ :body="replyingText"
+ />
<work-item-add-note
v-if="shouldShowReplyForm"
:notes-form="false"
@@ -235,6 +234,7 @@ export default {
:add-padding="true"
:autocomplete-data-sources="autocompleteDataSources"
:markdown-preview-path="markdownPreviewPath"
+ :is-internal-thread="note.internal"
@startReplying="showReplyForm"
@cancelEditing="hideReplyForm"
@replied="onReplied"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index 75b0970a89e..7ad424868c6 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -106,6 +106,7 @@ export default {
'note note-wrapper note-comment': true,
target: this.isTarget,
'inner-target': this.isTarget && !this.isFirstNote,
+ 'internal-note': this.note.internal,
};
},
showReply() {
@@ -147,8 +148,14 @@ export default {
currentUserId() {
return window.gon.current_user_id;
},
- canReportAbuse() {
- return getIdFromGraphQLId(this.author.id) !== this.currentUserId;
+ isCurrentUserAuthorOfNote() {
+ return getIdFromGraphQLId(this.author.id) === this.currentUserId;
+ },
+ isWorkItemAuthor() {
+ return getIdFromGraphQLId(this.workItem?.author?.id) === getIdFromGraphQLId(this.author.id);
+ },
+ projectName() {
+ return this.workItem?.project?.name;
},
},
apollo: {
@@ -179,7 +186,7 @@ export default {
this.isEditing = true;
updateDraft(this.autosaveKey, this.note.body);
},
- async updateNote(newText) {
+ async updateNote({ commentText }) {
try {
this.isEditing = false;
await this.$apollo.mutate({
@@ -187,7 +194,7 @@ export default {
variables: {
input: {
id: this.note.id,
- body: newText,
+ body: commentText,
},
},
optimisticResponse: {
@@ -195,14 +202,14 @@ export default {
errors: [],
note: {
...this.note,
- bodyHtml: renderMarkdown(newText),
+ bodyHtml: renderMarkdown(commentText),
},
},
},
});
clearDraft(this.autosaveKey);
} catch (error) {
- updateDraft(this.autosaveKey, newText);
+ updateDraft(this.autosaveKey, commentText);
this.isEditing = true;
this.$emit('error', __('Something went wrong when updating a comment. Please try again'));
Sentry.captureException(error);
@@ -309,6 +316,7 @@ export default {
:created-at="note.createdAt"
:note-id="note.id"
:note-url="note.url"
+ :is-internal-note="note.internal"
>
<span v-if="note.createdAt" class="d-none d-sm-inline">&middot;</span>
</note-header>
@@ -321,7 +329,12 @@ export default {
:note-id="note.id"
:is-author-an-assignee="isAuthorAnAssignee"
:show-assign-unassign="canSetWorkItemMetadata"
- :can-report-abuse="canReportAbuse"
+ :can-report-abuse="!isCurrentUserAuthorOfNote"
+ :is-work-item-author="isWorkItemAuthor"
+ :work-item-type="workItemType"
+ :is-author-contributor="note.authorIsContributor"
+ :max-access-level-of-author="note.maxAccessLevelOfAuthor"
+ :project-name="projectName"
@startReplying="showReplyForm"
@startEditing="startEditing"
@error="($event) => $emit('error', $event)"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index 93f21f4fad8..b32a8c78c93 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -1,7 +1,14 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlButton,
+ GlIcon,
+ GlTooltipDirective,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
@@ -20,10 +27,11 @@ export default {
components: {
GlButton,
GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
ReplyButton,
- GlDropdown,
- GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ UserAccessRoleBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -67,6 +75,30 @@ export default {
required: false,
default: false,
},
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ isWorkItemAuthor: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isAuthorContributor: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ maxAccessLevelOfAuthor: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ projectName: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
assignUserActionText() {
@@ -74,7 +106,24 @@ export default {
? this.$options.i18n.unassignUserText
: this.$options.i18n.assignUserText;
},
+ displayAuthorBadgeText() {
+ return sprintf(__('This user is the author of this %{workItemType}.'), {
+ workItemType: this.workItemType.toLowerCase(),
+ });
+ },
+ displayMemberBadgeText() {
+ return sprintf(__('This user has the %{access} role in the %{name} project.'), {
+ access: this.maxAccessLevelOfAuthor.toLowerCase(),
+ name: this.projectName,
+ });
+ },
+ displayContributorBadgeText() {
+ return sprintf(__('This user has previously committed to the %{name} project.'), {
+ name: this.projectName,
+ });
+ },
},
+
methods: {
async setAwardEmoji(name) {
try {
@@ -98,12 +147,43 @@ export default {
Sentry.captureException(error);
}
},
+ emitEvent(eventName) {
+ this.$emit(eventName);
+ this.$refs.dropdown.close();
+ },
},
};
</script>
<template>
<div class="note-actions">
+ <user-access-role-badge
+ v-if="isWorkItemAuthor"
+ v-gl-tooltip
+ :title="displayAuthorBadgeText"
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
+ data-testid="author-badge"
+ >
+ {{ __('Author') }}
+ </user-access-role-badge>
+ <user-access-role-badge
+ v-if="maxAccessLevelOfAuthor"
+ v-gl-tooltip
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
+ :title="displayMemberBadgeText"
+ data-testid="max-access-level-badge"
+ >
+ {{ maxAccessLevelOfAuthor }}
+ </user-access-role-badge>
+ <user-access-role-badge
+ v-else-if="isAuthorContributor"
+ v-gl-tooltip
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
+ :title="displayContributorBadgeText"
+ data-testid="contributor-badge"
+ >
+ {{ __('Contributor') }}
+ </user-access-role-badge>
<emoji-picker
v-if="showAwardEmoji && glFeatures.workItemsMvc2"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
@@ -135,46 +215,54 @@ export default {
:aria-label="$options.i18n.editButtonText"
@click="$emit('startEditing')"
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
+ ref="dropdown"
v-gl-tooltip
data-testid="work-item-note-actions"
icon="ellipsis_v"
text-sr-only
- right
- :text="$options.i18n.moreActionsText"
+ placement="right"
+ :toggle-text="$options.i18n.moreActionsText"
:title="$options.i18n.moreActionsText"
category="tertiary"
no-caret
>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
v-if="canReportAbuse"
data-testid="abuse-note-action"
- @click="$emit('reportAbuse')"
+ @action="emitEvent('reportAbuse')"
>
- {{ $options.i18n.reportAbuseText }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ $options.i18n.reportAbuseText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
data-testid="copy-link-action"
:data-clipboard-text="noteUrl"
- @click="$emit('notifyCopyDone')"
+ @action="emitEvent('notifyCopyDone')"
>
- <span>{{ $options.i18n.copyLinkText }}</span>
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ $options.i18n.copyLinkText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="showAssignUnassign"
data-testid="assign-note-action"
- @click="$emit('assignUser')"
+ @action="emitEvent('assignUser')"
>
- {{ assignUserActionText }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ assignUserActionText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="showEdit"
- variant="danger"
data-testid="delete-note-action"
- @click="$emit('deleteNote')"
+ @action="emitEvent('deleteNote')"
>
- {{ $options.i18n.deleteNoteText }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item>
+ <span class="gl-text-red-500">{{ $options.i18n.deleteNoteText }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
index f053f6e1d7c..e4c25f2c93a 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
@@ -26,6 +26,11 @@ export default {
required: false,
default: '',
},
+ isInternalNote: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
author() {
@@ -36,12 +41,18 @@ export default {
username: window.gon.current_username,
};
},
+ entryClass() {
+ return {
+ 'note note-wrapper note-comment being-posted': true,
+ 'internal-note': this.isInternalNote,
+ };
+ },
},
};
</script>
<template>
- <timeline-entry-item class="note note-wrapper note-comment being-posted">
+ <timeline-entry-item :class="entryClass">
<div class="timeline-avatar gl-float-left">
<gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" />
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 8ea5873f73a..76a04bede61 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -8,11 +8,14 @@ import {
GlModalDirective,
GlToggle,
} from '@gitlab/ui';
+
import * as Sentry from '@sentry/browser';
-import { s__ } from '~/locale';
+
+import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import toast from '~/vue_shared/plugins/global_toast';
import { isLoggedIn } from '~/lib/utils/common_utils';
+
import {
sprintfWorkItem,
I18N_WORK_ITEM_DELETE,
@@ -22,10 +25,15 @@ import {
TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
TEST_ID_DELETE_ACTION,
TEST_ID_PROMOTE_ACTION,
+ TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
+ TEST_ID_COPY_REFERENCE_ACTION,
WIDGET_TYPE_NOTIFICATIONS,
I18N_WORK_ITEM_ERROR_CONVERTING,
WORK_ITEM_TYPE_VALUE_KEY_RESULT,
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
+ I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL,
+ I18N_WORK_ITEM_ERROR_COPY_REFERENCE,
+ I18N_WORK_ITEM_ERROR_COPY_EMAIL,
} from '../constants';
import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql';
@@ -38,6 +46,9 @@ export default {
notifications: s__('WorkItem|Notifications'),
notificationOn: s__('WorkItem|Notifications turned on.'),
notificationOff: s__('WorkItem|Notifications turned off.'),
+ copyReference: __('Copy reference'),
+ referenceCopied: __('Reference copied'),
+ emailAddressCopied: __('Email address copied'),
},
components: {
GlDropdown,
@@ -55,6 +66,8 @@ export default {
notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ copyReferenceTestId: TEST_ID_COPY_REFERENCE_ACTION,
+ copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
deleteActionTestId: TEST_ID_DELETE_ACTION,
promoteActionTestId: TEST_ID_PROMOTE_ACTION,
inject: ['fullPath'],
@@ -99,6 +112,21 @@ export default {
required: false,
default: false,
},
+ workItemReference: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemCreateNoteEmail: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
apollo: {
workItemTypes: {
@@ -122,6 +150,15 @@ export default {
deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType),
areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType),
convertError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CONVERTING, this.workItemType),
+ copyCreateNoteEmail: sprintfWorkItem(
+ I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL,
+ this.workItemType,
+ ),
+ copyReferenceError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_COPY_REFERENCE, this.workItemType),
+ copyCreateNoteEmailError: sprintfWorkItem(
+ I18N_WORK_ITEM_ERROR_COPY_EMAIL,
+ this.workItemType,
+ ),
};
},
canPromoteToObjective() {
@@ -142,10 +179,19 @@ export default {
},
},
methods: {
+ copyToClipboard(text, message) {
+ if (this.isModal) {
+ navigator.clipboard.writeText(text);
+ }
+ toast(message);
+ },
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
this.$emit('toggleWorkItemConfidentiality', !this.isConfidential);
},
+ handleDelete() {
+ this.$refs.modal.show();
+ },
handleDeleteWorkItem() {
this.track('click_delete_work_item');
this.$emit('deleteWorkItem');
@@ -284,17 +330,35 @@ export default {
: $options.i18n.enableTaskConfidentiality
}}</gl-dropdown-item
>
+ </template>
+ <gl-dropdown-item
+ ref="workItemReference"
+ :data-testid="$options.copyReferenceTestId"
+ :data-clipboard-text="workItemReference"
+ @click="copyToClipboard(workItemReference, $options.i18n.referenceCopied)"
+ >{{ $options.i18n.copyReference }}</gl-dropdown-item
+ >
+ <template v-if="$options.isLoggedIn && workItemCreateNoteEmail">
+ <gl-dropdown-item
+ ref="workItemCreateNoteEmail"
+ :data-testid="$options.copyCreateNoteEmailTestId"
+ :data-clipboard-text="workItemCreateNoteEmail"
+ @click="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)"
+ >{{ i18n.copyCreateNoteEmail }}</gl-dropdown-item
+ >
<gl-dropdown-divider v-if="canDelete" />
</template>
<gl-dropdown-item
v-if="canDelete"
- v-gl-modal="'work-item-confirm-delete'"
:data-testid="$options.deleteActionTestId"
variant="danger"
- >{{ i18n.deleteWorkItem }}</gl-dropdown-item
+ @click="handleDelete"
>
+ {{ i18n.deleteWorkItem }}
+ </gl-dropdown-item>
</gl-dropdown>
<gl-modal
+ ref="modal"
modal-id="work-item-confirm-delete"
:title="i18n.deleteWorkItem"
:ok-title="i18n.deleteWorkItem"
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 4e6583b65f8..d0d520ae5b1 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -126,8 +126,16 @@ export default {
assigneesTitleId() {
return uniqueId('assignees-title-');
},
+ deduplicatedUsers() {
+ return this.users.nodes.reduce((acc, current) => {
+ if (!acc.find((node) => node.user.id === current.user.id)) {
+ acc.push(current);
+ }
+ return acc;
+ }, []);
+ },
searchUsers() {
- return this.users.nodes.map((node) => addClass({ ...node, ...node.user }));
+ return this.deduplicatedUsers.map((node) => addClass({ ...node, ...node.user }));
},
pageInfo() {
return this.users.pageInfo;
diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
index 91f87be1233..144c29b8ec3 100644
--- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
+++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
@@ -1,17 +1,15 @@
<script>
import * as Sentry from '@sentry/browser';
+import { produce } from 'immer';
+
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import AwardsList from '~/vue_shared/components/awards_list.vue';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { TYPENAME_USER } from '~/graphql_shared/constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import {
- EMOJI_ACTION_REMOVE,
- EMOJI_ACTION_ADD,
- WIDGET_TYPE_AWARD_EMOJI,
- EMOJI_THUMBSDOWN,
- EMOJI_THUMBSUP,
-} from '../constants';
+
+import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import { EMOJI_THUMBSDOWN, EMOJI_THUMBSUP, WIDGET_TYPE_AWARD_EMOJI } from '../constants';
export default {
defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
@@ -20,59 +18,146 @@ export default {
AwardsList,
},
props: {
- workItem: {
- type: Object,
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemFullpath: {
+ type: String,
required: true,
},
awardEmoji: {
type: Object,
required: true,
},
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
currentUserId() {
return window.gon.current_user_id;
},
+ currentUserFullName() {
+ return window.gon.current_user_fullname;
+ },
/**
* Parse and convert award emoji list to a format that AwardsList can understand
*/
awards() {
- return this.awardEmoji.nodes.map((emoji, index) => ({
- id: index + 1,
+ return this.awardEmoji.nodes.map((emoji) => ({
name: emoji.name,
user: {
id: getIdFromGraphQLId(emoji.user.id),
+ name: emoji.user.name,
},
}));
},
},
methods: {
+ getAwards() {
+ return this.awardEmoji.nodes.map((emoji) => ({
+ name: emoji.name,
+ user: {
+ id: getIdFromGraphQLId(emoji.user.id),
+ name: emoji.user.name,
+ },
+ }));
+ },
+ isEmojiPresentForCurrentUser(name) {
+ return (
+ this.awards.findIndex(
+ (emoji) => emoji.name === name && emoji.user.id === this.currentUserId,
+ ) > -1
+ );
+ },
+ /**
+ * Prepare award emoji nodes based on emoji name
+ * and whether the user has toggled the emoji off or on
+ */
+ getAwardEmojiNodes(name, toggledOn) {
+ // If the emoji toggled on, add the emoji
+ if (toggledOn) {
+ // If emoji is already present in award list, no action is needed
+ if (this.isEmojiPresentForCurrentUser(name)) {
+ return this.awardEmoji.nodes;
+ }
+
+ // else make a copy of unmutable list and return the list after adding the new emoji
+ const awardEmojiNodes = [...this.awardEmoji.nodes];
+ awardEmojiNodes.push({
+ name,
+ __typename: 'AwardEmoji',
+ user: {
+ id: convertToGraphQLId(TYPENAME_USER, this.currentUserId),
+ name: this.currentUserFullName,
+ __typename: 'UserCore',
+ },
+ });
+
+ return awardEmojiNodes;
+ }
+
+ // else just filter the emoji
+ return this.awardEmoji.nodes.filter(
+ (emoji) =>
+ !(emoji.name === name && getIdFromGraphQLId(emoji.user.id) === this.currentUserId),
+ );
+ },
+ updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) {
+ const query = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.workItemFullpath, iid: this.workItemIid },
+ };
+
+ const sourceData = cache.readQuery(query);
+
+ const newData = produce(sourceData, (draftState) => {
+ const { widgets } = draftState.workspace.workItems.nodes[0];
+ const widgetAwardEmoji = widgets.find((widget) => widget.type === WIDGET_TYPE_AWARD_EMOJI);
+
+ widgetAwardEmoji.awardEmoji.nodes = this.getAwardEmojiNodes(name, toggledOn);
+ });
+
+ cache.writeQuery({ ...query, data: newData });
+ },
handleAward(name) {
// Decide action based on emoji is already present
- const action =
- this.awards.findIndex((emoji) => emoji.name === name) > -1
- ? EMOJI_ACTION_REMOVE
- : EMOJI_ACTION_ADD;
const inputVariables = {
- id: this.workItem.id,
- awardEmojiWidget: {
- action,
- name,
- },
+ awardableId: this.workItemId,
+ name,
};
this.$apollo
.mutate({
- mutation: updateWorkItemMutation,
+ mutation: updateAwardEmojiMutation,
variables: {
input: inputVariables,
},
- optimisticResponse: this.getOptimisticResponse({ name, action }),
+ optimisticResponse: {
+ awardEmojiToggle: {
+ errors: [],
+ toggledOn: !this.isEmojiPresentForCurrentUser(name),
+ },
+ },
+ update: (
+ cache,
+ {
+ data: {
+ awardEmojiToggle: { toggledOn },
+ },
+ },
+ ) => {
+ // update the cache of award emoji widget object
+ this.updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn });
+ },
})
.then(
({
data: {
- workItemUpdate: { errors },
+ awardEmojiToggle: { errors },
},
}) => {
if (errors?.length) {
@@ -85,46 +170,6 @@ export default {
Sentry.captureException(error);
});
},
- /**
- * Prepare workItemUpdate for optimistic response
- */
- getOptimisticResponse({ name, action }) {
- let awardEmojiNodes = [
- ...this.awardEmoji.nodes,
- {
- name,
- __typename: 'AwardEmoji',
- user: {
- id: convertToGraphQLId(TYPENAME_USER, this.currentUserId),
- __typename: 'UserCore',
- },
- },
- ];
- // Exclude the award emoji node in case of remove action
- if (action === EMOJI_ACTION_REMOVE) {
- awardEmojiNodes = [...this.awardEmoji.nodes.filter((emoji) => emoji.name !== name)];
- }
- return {
- workItemUpdate: {
- errors: [],
- workItem: {
- ...this.workItem,
- widgets: [
- {
- type: WIDGET_TYPE_AWARD_EMOJI,
- awardEmoji: {
- nodes: awardEmojiNodes,
- __typename: 'AwardEmojiConnection',
- },
- __typename: 'WorkItemWidgetAwardEmoji',
- },
- ],
- __typename: 'WorkItem',
- },
- __typename: 'WorkItemUpdatePayload',
- },
- };
- },
},
};
</script>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index a4cbc430b84..61dec21cae4 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlFormGroup } from '@gitlab/ui';
+import { GlAlert, GlButton, GlForm, GlFormGroup } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
@@ -7,8 +7,6 @@ import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_m
import { __, s__ } from '~/locale';
import EditedAt from '~/issues/show/components/edited.vue';
import Tracking from '~/tracking';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { autocompleteDataSources, markdownPreviewPath } from '../utils';
import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
@@ -22,12 +20,12 @@ export default {
EditedAt,
GlAlert,
GlButton,
+ GlForm,
GlFormGroup,
MarkdownEditor,
- MarkdownField,
WorkItemDescriptionRendered,
},
- mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [Tracking.mixin()],
inject: ['fullPath'],
props: {
workItemId: {
@@ -227,111 +225,84 @@ export default {
<template>
<div>
- <gl-form-group
- v-if="isEditing"
- class="gl-mb-5 gl-border-t gl-pt-6"
- :label="__('Description')"
- label-for="work-item-description"
- >
- <markdown-editor
- v-if="glFeatures.workItemsMvc"
- class="gl-my-3 common-note-form"
- :value="descriptionText"
- :render-markdown-path="markdownPreviewPath"
- :markdown-docs-path="$options.markdownDocsPath"
- :form-field-props="formFieldProps"
- :quick-actions-docs-path="$options.quickActionsDocsPath"
- :autocomplete-data-sources="autocompleteDataSources"
- enable-autocomplete
- supports-quick-actions
- autofocus
- @input="setDescriptionText"
- @keydown.meta.enter="updateWorkItem"
- @keydown.ctrl.enter="updateWorkItem"
- />
- <markdown-field
- v-else
- can-attach-file
- :textarea-value="descriptionText"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
- :markdown-docs-path="$options.markdownDocsPath"
- :quick-actions-docs-path="$options.quickActionsDocsPath"
- :autocomplete-data-sources="autocompleteDataSources"
- class="gl-px-3 bordered-box gl-mt-5"
+ <gl-form v-if="isEditing" @submit.prevent="updateWorkItem" @reset.prevent="cancelEditing">
+ <gl-form-group
+ class="gl-mb-5 gl-border-t gl-pt-6 common-note-form"
+ :label="__('Description')"
+ label-for="work-item-description"
>
- <template #textarea>
- <textarea
- v-bind="formFieldProps"
- ref="textarea"
- v-model="descriptionText"
- :disabled="isSubmitting"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="true"
- @keydown.meta.enter="updateWorkItem"
- @keydown.ctrl.enter="updateWorkItem"
- @keydown.exact.esc.stop="cancelEditing"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
- <div class="gl-display-flex">
- <gl-alert
- v-if="hasConflicts"
- :dismissible="false"
- variant="danger"
- class="gl-w-full"
- data-testid="work-item-description-conflicts"
- >
- <p>
- {{
- s__(
- "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.",
- )
- }}
- </p>
- <details class="gl-mb-5">
- <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary>
- <textarea
- class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3"
- readonly
- :value="conflictedDescription"
- ></textarea>
- </details>
- <template #actions>
+ <markdown-editor
+ class="gl-my-5"
+ :value="descriptionText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :form-field-props="formFieldProps"
+ :quick-actions-docs-path="$options.quickActionsDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
+ enable-autocomplete
+ supports-quick-actions
+ autofocus
+ @input="setDescriptionText"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ />
+ <div class="gl-display-flex">
+ <gl-alert
+ v-if="hasConflicts"
+ :dismissible="false"
+ variant="danger"
+ class="gl-w-full"
+ data-testid="work-item-description-conflicts"
+ >
+ <p>
+ {{
+ s__(
+ "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.",
+ )
+ }}
+ </p>
+ <details class="gl-mb-5">
+ <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary>
+ <textarea
+ class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3"
+ readonly
+ :value="conflictedDescription"
+ ></textarea>
+ </details>
+ <template #actions>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ s__('WorkItem|Save and overwrite') }}
+ </gl-button>
+ <gl-button
+ category="secondary"
+ class="gl-ml-3"
+ data-testid="cancel"
+ @click="cancelEditing"
+ >{{ s__('WorkItem|Discard changes') }}
+ </gl-button>
+ </template>
+ </gl-alert>
+ <template v-else>
<gl-button
category="primary"
variant="confirm"
:loading="isSubmitting"
data-testid="save-description"
- @click="updateWorkItem"
- >{{ s__('WorkItem|Save and overwrite') }}
+ type="submit"
+ >{{ __('Save') }}
</gl-button>
- <gl-button
- category="secondary"
- class="gl-ml-3"
- data-testid="cancel"
- @click="cancelEditing"
- >{{ s__('WorkItem|Discard changes') }}
+ <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" type="reset"
+ >{{ __('Cancel') }}
</gl-button>
</template>
- </gl-alert>
- <template v-else>
- <gl-button
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- data-testid="save-description"
- @click="updateWorkItem"
- >{{ __('Save') }}
- </gl-button>
- <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing"
- >{{ __('Cancel') }}
- </gl-button>
- </template>
- </div>
- </gl-form-group>
+ </div>
+ </gl-form-group>
+ </gl-form>
<work-item-description-rendered
v-else
:work-item-description="workItemDescription"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 0f1af44e8a1..1ac40fe7dcb 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,6 +1,5 @@
<script>
import { isEmpty } from 'lodash';
-import { produce } from 'immer';
import {
GlAlert,
GlSkeletonLoader,
@@ -11,15 +10,12 @@ import {
GlTooltipDirective,
GlEmptyState,
} from '@gitlab/ui';
-import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
-import * as Sentry from '@sentry/browser';
+import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw';
import { s__ } from '~/locale';
import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
-import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
@@ -111,11 +107,6 @@ export default {
required: false,
default: false,
},
- workItemId: {
- type: String,
- required: false,
- default: null,
- },
workItemIid: {
type: String,
required: false,
@@ -128,16 +119,12 @@ export default {
},
},
data() {
- const workItemId = getParameterByName('work_item_id');
-
return {
error: undefined,
updateError: undefined,
workItem: {},
updateInProgress: false,
- modalWorkItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
- : null,
+ modalWorkItemId: undefined,
modalWorkItemIid: getParameterByName('work_item_iid'),
isReportDrawerOpen: false,
reportedUrl: '',
@@ -279,7 +266,7 @@ export default {
// Once more types are moved to have Work Items involved
// we need to handle this properly.
if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) {
- return `../../issues/${this.parentWorkItem?.iid}`;
+ return `../../-/issues/${this.parentWorkItem?.iid}`;
}
return this.parentWorkItem?.webUrl;
},
@@ -347,10 +334,10 @@ export default {
},
},
mounted() {
- if (this.modalWorkItemId || this.modalWorkItemIid) {
+ if (this.modalWorkItemIid) {
this.openInModal({
event: undefined,
- modalWorkItem: { id: this.modalWorkItemId, iid: this.modalWorkItemIid },
+ modalWorkItem: { iid: this.modalWorkItemIid },
});
}
},
@@ -410,71 +397,6 @@ export default {
this.error = this.$options.i18n.fetchError;
document.title = s__('404|Not found');
},
- addChild(child) {
- const { defaultClient: client } = this.$apollo.provider.clients;
- this.toggleChildFromCache(child, child.id, client);
- },
- toggleChildFromCache(workItem, childId, store) {
- const query = {
- query: workItemByIidQuery,
- variables: { fullPath: this.fullPath, iid: this.workItemIid },
- };
-
- const sourceData = store.readQuery(query);
-
- const newData = produce(sourceData, (draftState) => {
- const { widgets } = draftState.workspace.workItems.nodes[0];
- const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
-
- const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
-
- if (index >= 0) {
- widgetHierarchy.children.nodes.splice(index, 1);
- } else {
- widgetHierarchy.children.nodes.push(workItem);
- }
- });
-
- store.writeQuery({ ...query, data: newData });
- },
- async updateWorkItem(workItem, childId, parentId) {
- return this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId } } },
- update: (store) => this.toggleChildFromCache(workItem, childId, store),
- });
- },
- async undoChildRemoval(workItem, childId) {
- try {
- const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id);
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast?.hide();
- }
- } catch (error) {
- this.updateError = s__('WorkItem|Something went wrong while undoing child removal.');
- Sentry.captureException(error);
- } finally {
- this.activeToast?.hide();
- }
- },
- async removeChild({ id }) {
- try {
- const { data } = await this.updateWorkItem(null, id, null);
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
- action: {
- text: s__('WorkItem|Undo'),
- onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, id),
- },
- });
- }
- } catch (error) {
- this.updateError = s__('WorkItem|Something went wrong while removing child.');
- Sentry.captureException(error);
- }
- },
updateHasNotes() {
this.$emit('has-notes');
},
@@ -593,7 +515,6 @@ export default {
@error="updateError = $event"
/>
<work-item-actions
- v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
:subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
@@ -602,6 +523,9 @@ export default {
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
+ :work-item-reference="workItem.reference"
+ :work-item-create-note-email="workItem.createNoteEmail"
+ :is-modal="isModal"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
@@ -713,8 +637,10 @@ export default {
/>
<work-item-award-emoji
v-if="workItemAwardEmoji"
- :work-item="workItem"
+ :work-item-id="workItem.id"
+ :work-item-fullpath="workItem.project.fullPath"
:award-emoji="workItemAwardEmoji.awardEmoji"
+ :work-item-iid="workItemIid"
@error="updateError = $event"
/>
<work-item-tree
@@ -726,8 +652,6 @@ export default {
:children="children"
:can-update="canUpdate"
:confidential="workItem.confidential"
- @addWorkItemChild="addChild"
- @removeChild="removeChild"
@show-modal="openInModal"
/>
<work-item-notes
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index f8422dda211..ce06a744983 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -31,16 +31,12 @@ export default {
data() {
return {
error: undefined,
- updatedWorkItemId: null,
updatedWorkItemIid: null,
isModalShown: false,
hasNotes: false,
};
},
computed: {
- displayedWorkItemId() {
- return this.updatedWorkItemId || this.workItemId;
- },
displayedWorkItemIid() {
return this.updatedWorkItemIid || this.workItemIid;
},
@@ -72,7 +68,6 @@ export default {
});
},
closeModal() {
- this.updatedWorkItemId = null;
this.updatedWorkItemIid = null;
this.error = '';
this.isModalShown = false;
@@ -88,7 +83,6 @@ export default {
this.$refs.modal.show();
},
updateModal($event, workItem) {
- this.updatedWorkItemId = workItem.id;
this.updatedWorkItemIid = workItem.iid;
this.$emit('update-modal', $event, workItem);
},
@@ -126,7 +120,6 @@ export default {
<work-item-detail
is-modal
- :work-item-id="displayedWorkItemId"
:work-item-iid="displayedWorkItemIid"
class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate"
@close="hide"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index 636c9357170..07aa98a1b44 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -26,9 +26,6 @@ export default function initWorkItemLinks() {
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
apolloProvider,
- components: {
- WorkItemLinks,
- },
provide: {
fullPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
@@ -39,9 +36,10 @@ export default function initWorkItemLinks() {
reportAbusePath: wiReportAbusePath,
},
render: (createElement) =>
- createElement('work-item-links', {
+ createElement(WorkItemLinks, {
props: {
issuableId: parseInt(workItemLinksRoot.dataset.issuableId, 10),
+ issuableIid: parseInt(workItemLinksRoot.dataset.issuableIid, 10),
},
}),
});
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
index 4b6f581d76d..bf427feaa35 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
@@ -1,16 +1,19 @@
<script>
+import * as Sentry from '@sentry/browser';
import produce from 'immer';
import Draggable from 'vuedraggable';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { s__ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants';
-import { findHierarchyWidgets, getWorkItemQuery } from '../../utils';
-import workItemQuery from '../../graphql/work_item.query.graphql';
-import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import { findHierarchyWidgets } from '../../utils';
+import { addHierarchyChild, removeHierarchyChild } from '../../graphql/cache_utils';
import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WorkItemLinkChild from './work_item_link_child.vue';
export default {
@@ -42,11 +45,6 @@ export default {
required: false,
default: false,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -78,44 +76,71 @@ export default {
.map((child) => findHierarchyWidgets(child.widgets) || {})
.some((hierarchy) => hierarchy.hasChildren);
},
- queryVariables() {
- return this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: this.workItemIid,
- }
- : {
- id: this.workItemId,
- };
- },
},
methods: {
- addWorkItemQuery({ id, iid }) {
- const variables = this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid,
- }
- : {
- id,
- };
+ async removeChild(child) {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: child.id, hierarchyWidget: { parentId: null } } },
+ update: (cache) => removeHierarchyChild(cache, this.fullPath, this.workItemIid, child),
+ });
+
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors);
+ }
+
+ this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: (_, toast) => {
+ this.undoChildRemoval(child);
+ toast.hide();
+ },
+ },
+ });
+ } catch (error) {
+ this.$emit('error', s__('WorkItem|Something went wrong while removing child.'));
+ Sentry.captureException(error);
+ }
+ },
+ async undoChildRemoval(child) {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: child.id, hierarchyWidget: { parentId: this.workItemId } } },
+ update: (cache) => addHierarchyChild(cache, this.fullPath, this.workItemIid, child),
+ });
+
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors);
+ }
+
+ this.$toast.show(s__('WorkItem|Child removal reverted'));
+ } catch (error) {
+ this.$emit('error', s__('WorkItem|Something went wrong while undoing child removal.'));
+ Sentry.captureException(error);
+ }
+ },
+ addWorkItemQuery({ iid }) {
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query() {
- return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid,
},
- variables,
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
context: {
isSingleRequest: true,
},
});
},
- prefetchWorkItem({ id, iid }) {
+ prefetchWorkItem({ iid }) {
if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) {
this.prefetch = setTimeout(
- () => this.addWorkItemQuery({ id, iid }),
+ () => this.addWorkItemQuery({ iid }),
DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
);
}
@@ -180,12 +205,13 @@ export default {
},
update: (store) => {
store.updateQuery(
- { query: getWorkItemQuery(this.fetchByIid), variables: this.queryVariables },
+ {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.fullPath, iid: this.workItemIid },
+ },
(sourceData) =>
produce(sourceData, (draftData) => {
- const widgets = this.fetchByIid
- ? draftData.workspace.workItems.nodes[0].widgets
- : draftData.workItem.widgets;
+ const { widgets } = draftData.workspace.workItems.nodes[0];
const hierarchyWidget = findHierarchyWidgets(widgets);
hierarchyWidget.children.nodes = updatedChildren;
}),
@@ -210,7 +236,8 @@ export default {
},
)
.catch((error) => {
- this.updateError = error.message;
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
});
},
},
@@ -235,7 +262,7 @@ export default {
:has-indirect-children="hasIndirectChildren"
@mouseover="prefetchWorkItem(child)"
@mouseout="clearPrefetching"
- @removeChild="$emit('removeChild', $event)"
+ @removeChild="removeChild"
@click="$emit('show-modal', { event: $event, child: $event.childItem || child })"
/>
</component>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 5728e33880e..b9fc92304c0 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -3,7 +3,6 @@ import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective }
import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { isMetaKey } from '~/lib/utils/common_utils';
@@ -11,10 +10,8 @@ import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants';
-import { findHierarchyWidgetChildren, getWorkItemQuery } from '../../utils';
-import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql';
-import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql';
-import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import { findHierarchyWidgetChildren } from '../../utils';
+import { removeHierarchyChild } from '../../graphql/cache_utils';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
@@ -40,25 +37,30 @@ export default {
props: {
issuableId: {
type: Number,
- required: false,
- default: null,
+ required: true,
+ },
+ issuableIid: {
+ type: Number,
+ required: true,
},
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
return {
- id: this.issuableGid,
+ fullPath: this.fullPath,
+ iid: this.iid,
};
},
+ update(data) {
+ return data.workspace.workItems.nodes[0] ?? {};
+ },
context: {
isSingleRequest: true,
},
skip() {
- return !this.issuableId;
+ return !this.iid;
},
error(e) {
this.error = e.message || this.$options.i18n.fetchError;
@@ -88,8 +90,6 @@ export default {
return {
isShownAddForm: false,
activeChild: {},
- activeToast: null,
- prefetchedWorkItem: null,
error: undefined,
parentIssue: null,
formType: null,
@@ -100,12 +100,12 @@ export default {
};
},
computed: {
- fetchByIid() {
- return false;
- },
confidential() {
return this.parentIssue?.confidential || this.workItem?.confidential || false;
},
+ iid() {
+ return String(this.issuableIid);
+ },
issuableIteration() {
return this.parentIssue?.iteration;
},
@@ -138,9 +138,6 @@ export default {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
},
- mounted() {
- this.addWorkItemQuery(getParameterByName('work_item_iid'));
- },
methods: {
showAddForm(formType) {
this.$refs.wrapper.show();
@@ -167,82 +164,13 @@ export default {
this.updateWorkItemIdUrlQuery();
},
handleWorkItemDeleted(child) {
- this.removeHierarchyChild(child);
- this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
+ const { defaultClient: cache } = this.$apollo.provider.clients;
+ removeHierarchyChild(cache, this.fullPath, this.iid, child);
+ this.$toast.show(s__('WorkItem|Task deleted'));
},
updateWorkItemIdUrlQuery({ iid } = {}) {
updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true });
},
- async addHierarchyChild(workItem) {
- return this.$apollo.mutate({
- mutation: addHierarchyChildMutation,
- variables: { id: this.issuableGid, workItem },
- });
- },
- async removeHierarchyChild(workItem) {
- return this.$apollo.mutate({
- mutation: removeHierarchyChildMutation,
- variables: { id: this.issuableGid, workItem },
- });
- },
- async undoChildRemoval(workItem, childId) {
- const { data } = await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId: this.issuableGid } } },
- });
-
- await this.addHierarchyChild(workItem);
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast?.hide();
- }
- },
- async removeChild(workItem) {
- const childId = workItem.id;
- const { data } = await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
- });
-
- await this.removeHierarchyChild(workItem);
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
- action: {
- text: s__('WorkItem|Undo'),
- onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId),
- },
- });
- }
- },
- addWorkItemQuery(iid) {
- if (!iid) {
- return;
- }
-
- this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query: workItemByIidQuery,
- variables: {
- fullPath: this.fullPath,
- iid,
- },
- update(data) {
- return data.workspace.workItems.nodes[0];
- },
- context: {
- isSingleRequest: true,
- },
- });
- },
- prefetchWorkItem({ iid }) {
- this.prefetch = setTimeout(
- () => this.addWorkItemQuery(iid),
- DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
- );
- },
- clearPrefetching() {
- clearTimeout(this.prefetch);
- },
toggleReportAbuseDrawer(isOpen, reply = {}) {
this.isReportDrawerOpen = isOpen;
this.reportedUrl = reply.url;
@@ -321,6 +249,7 @@ export default {
ref="wiLinksForm"
data-testid="add-links-form"
:issuable-gid="issuableGid"
+ :work-item-iid="iid"
:children-ids="childrenIds"
:parent-confidential="confidential"
:parent-iteration="issuableIteration"
@@ -328,13 +257,13 @@ export default {
:form-type="formType"
:parent-work-item-type="workItem.workItemType.name"
@cancel="hideAddForm"
- @addWorkItemChild="addHierarchyChild"
/>
<work-item-children-wrapper
:children="children"
:can-update="canUpdate"
:work-item-id="issuableGid"
- @removeChild="removeChild"
+ :work-item-iid="iid"
+ @error="error = $event"
@show-modal="openChild"
/>
<work-item-detail-modal
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 51c83784d06..289a48b5eaf 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -13,7 +13,8 @@ import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
-import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import { addHierarchyChild } from '../../graphql/cache_utils';
+import projectWorkItemTypesQuery from '../../graphql/project_work_item_types.query.graphql';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
@@ -48,6 +49,11 @@ export default {
required: false,
default: null,
},
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
childrenIds: {
type: Array,
required: false,
@@ -292,13 +298,14 @@ export default {
variables: {
input: this.workItemInput,
},
+ update: (cache, { data }) =>
+ addHierarchyChild(cache, this.fullPath, this.workItemIid, data.workItemCreate.workItem),
})
.then(({ data }) => {
if (data.workItemCreate?.errors?.length) {
[this.error] = data.workItemCreate.errors;
} else {
this.unsetError();
- this.$emit('addWorkItemChild', data.workItemCreate.workItem);
}
})
.catch(() => {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index cbca78e4b14..44e8dac79c4 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -6,7 +6,6 @@ import {
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
} from '../../constants';
-import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import OkrActionsSplitButton from './okr_actions_split_button.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
@@ -61,10 +60,10 @@ export default {
},
data() {
return {
+ error: undefined,
isShownAddForm: false,
formType: null,
childType: null,
- prefetchedWorkItem: null,
};
},
computed: {
@@ -95,31 +94,17 @@ export default {
showModal({ event, child }) {
this.$emit('show-modal', { event, modalWorkItem: child });
},
- addWorkItemQuery(iid) {
- if (!iid) {
- return;
- }
-
- this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query: workItemByIidQuery,
- variables: {
- fullPath: this.fullPath,
- iid,
- },
- update(data) {
- return data.workspace.workItems.nodes[0];
- },
- context: {
- isSingleRequest: true,
- },
- });
- },
},
};
</script>
<template>
- <widget-wrapper ref="wrapper" data-testid="work-item-tree">
+ <widget-wrapper
+ ref="wrapper"
+ :error="error"
+ data-testid="work-item-tree"
+ @dismissAlert="error = undefined"
+ >
<template #header>
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }}
</template>
@@ -151,12 +136,12 @@ export default {
ref="wiLinksForm"
data-testid="add-tree-form"
:issuable-gid="workItemId"
+ :work-item-iid="workItemIid"
:form-type="formType"
:parent-work-item-type="parentWorkItemType"
:children-type="childType"
:children-ids="childrenIds"
:parent-confidential="confidential"
- @addWorkItemChild="$emit('addWorkItemChild', $event)"
@cancel="hideAddForm"
/>
<work-item-children-wrapper
@@ -165,8 +150,7 @@ export default {
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-type="workItemType"
- fetch-by-iid
- @removeChild="$emit('removeChild', $event)"
+ @error="error = $event"
@show-modal="showModal"
/>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 092b90a5731..8fc460294e6 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -21,6 +21,7 @@ import {
updateCacheAfterDeletingNote,
} from '~/work_items/graphql/cache_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
+import { collapseSystemNotes } from '~/work_items/notes/collapse_utils';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemHistoryOnlyFilterNote from '~/work_items/components/notes/work_item_history_only_filter_note.vue';
import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql';
@@ -128,7 +129,9 @@ export default {
notesArray() {
const notes = this.workItemNotes?.nodes || [];
- const visibleNotes = notes.filter((note) => {
+ let visibleNotes = collapseSystemNotes(notes);
+
+ visibleNotes = visibleNotes.filter((note) => {
const isSystemNote = this.isSystemNote(note);
if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS && isSystemNote) {
@@ -145,6 +148,7 @@ export default {
if (this.sortOrder === DESC) {
return [...visibleNotes].reverse();
}
+
return visibleNotes;
},
commentsDisabled() {
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 6710c762c2e..f3beaebf403 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -92,6 +92,17 @@ export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__(
'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.',
);
+export const I18N_WORK_ITEM_ERROR_COPY_REFERENCE = s__(
+ 'WorkItem|Something went wrong while copying the %{workItemType} reference. Please try again.',
+);
+export const I18N_WORK_ITEM_ERROR_COPY_EMAIL = s__(
+ 'WorkItem|Something went wrong while copying the %{workItemType} email address. Please try again.',
+);
+
+export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__(
+ 'WorkItem|Copy %{workItemType} email address',
+);
+
export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
return capitalizeFirstCharacter(
@@ -217,6 +228,8 @@ export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action'
export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form';
export const TEST_ID_DELETE_ACTION = 'delete-action';
export const TEST_ID_PROMOTE_ACTION = 'promote-action';
+export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action';
+export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action';
export const ADD = 'ADD';
export const MARK_AS_DONE = 'MARK_AS_DONE';
diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql
deleted file mode 100644
index 30a5d2388b1..00000000000
--- a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-mutation addHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) {
- addHierarchyChild(id: $id, workItem: $workItem) @client
-}
diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
index 85b88990cd6..bed09974ef5 100644
--- a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
@@ -2,5 +2,6 @@ fragment AwardEmojiFragment on AwardEmoji {
name
user {
id
+ name
}
}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 455d8b8ae7b..03b45a45c39 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -1,5 +1,7 @@
import { produce } from 'immer';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import { findHierarchyWidgetChildren } from '~/work_items/utils';
const isNotesWidget = (widget) => widget.type === WIDGET_TYPE_NOTES;
@@ -17,7 +19,6 @@ const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => {
* @param currentNotes
* @param subscriptionData
*/
-
export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) => {
if (!subscriptionData.data?.workItemNoteCreated) {
return currentNotes;
@@ -49,7 +50,6 @@ export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData) =>
* @param currentNotes
* @param subscriptionData
*/
-
export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => {
if (!subscriptionData.data?.workItemNoteDeleted) {
return currentNotes;
@@ -86,3 +86,37 @@ export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) =>
updateNotesWidgetDataInDraftData(draftData, notesWidget);
});
};
+
+export const addHierarchyChild = (cache, fullPath, iid, workItem) => {
+ const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } };
+ const sourceData = cache.readQuery(queryArgs);
+
+ if (!sourceData) {
+ return;
+ }
+
+ cache.writeQuery({
+ ...queryArgs,
+ data: produce(sourceData, (draftState) => {
+ findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]).push(workItem);
+ }),
+ });
+};
+
+export const removeHierarchyChild = (cache, fullPath, iid, workItem) => {
+ const queryArgs = { query: workItemByIidQuery, variables: { fullPath, iid } };
+ const sourceData = cache.readQuery(queryArgs);
+
+ if (!sourceData) {
+ return;
+ }
+
+ cache.writeQuery({
+ ...queryArgs,
+ data: produce(sourceData, (draftState) => {
+ const children = findHierarchyWidgetChildren(draftState.workspace.workItems.nodes[0]);
+ const index = children.findIndex((child) => child.id === workItem.id);
+ children.splice(index, 1);
+ }),
+ });
+};
diff --git a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql
index 5050aa7cbda..3286895215f 100644
--- a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item_note.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
mutation createWorkItemNote($input: CreateNoteInput!) {
createNote(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql
index 3da8e7677e4..eb52eb912e7 100644
--- a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item_note.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
mutation updateWorkItemNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql
index 58561e33e53..635faf27892 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
-#import "./work_item_note.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
fragment WorkItemDiscussionNote on Note {
id
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
index 93616c39e55..c8b7d379074 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
@@ -10,6 +10,8 @@ fragment WorkItemNote on Note {
createdAt
lastEditedAt
url
+ authorIsContributor
+ maxAccessLevelOfAuthor
lastEditedBy {
...User
webPath
@@ -28,4 +30,11 @@ fragment WorkItemNote on Note {
resolveNote
repositionNote
}
+ systemNoteMetadata {
+ id
+ descriptionVersion {
+ id
+ description
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql
index c68d5f491cf..1a6f4e44ee0 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql
@@ -1,4 +1,4 @@
-#import "./work_item_note.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
subscription workItemNoteUpdated($noteableId: NoteableID) {
workItemNoteUpdated(noteableId: $noteableId) {
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql
index 6b37c68cb43..6022b280d72 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "./work_item_note.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/notes/work_item_note.fragment.graphql"
query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) {
workspace: project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql
deleted file mode 100644
index 3fece06eefa..00000000000
--- a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-mutation removeHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) {
- removeHierarchyChild(id: $id, workItem: $workItem) @client
-}
diff --git a/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql
new file mode 100644
index 00000000000..1506d13d2da
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_award_emoji.mutation.graphql
@@ -0,0 +1,6 @@
+mutation updateWorkItemAwardEmojiWidget($input: AwardEmojiToggleInput!) {
+ awardEmojiToggle(input: $input) {
+ errors
+ toggledOn
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index b045796579b..1ae5617f04d 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -11,10 +11,13 @@ fragment WorkItem on WorkItem {
createdAt
updatedAt
closedAt
+ reference(full: true)
+ createNoteEmail
project {
id
fullPath
archived
+ name
}
author {
...Author
@@ -27,8 +30,9 @@ fragment WorkItem on WorkItem {
userPermissions {
deleteWorkItem
updateWorkItem
- setWorkItemMetadata @client
adminParentLink
+ setWorkItemMetadata
+ createNote
}
widgets {
...WorkItemWidgets
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
deleted file mode 100644
index 3b46fed97ec..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-#import "./work_item.fragment.graphql"
-
-query workItem($id: WorkItemID!) {
- workItem(id: $id) {
- ...WorkItem
- }
-}
diff --git a/app/assets/javascripts/work_items/mixins/description_version_history.js b/app/assets/javascripts/work_items/mixins/description_version_history.js
new file mode 100644
index 00000000000..d1006e37a70
--- /dev/null
+++ b/app/assets/javascripts/work_items/mixins/description_version_history.js
@@ -0,0 +1,14 @@
+// Placeholder for GitLab FOSS
+// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js
+export default {
+ computed: {
+ canSeeDescriptionVersion() {},
+ displayDeleteButton() {},
+ shouldShowDescriptionVersion() {},
+ descriptionVersionToggleIcon() {},
+ },
+ methods: {
+ toggleDescriptionVersion() {},
+ deleteDescriptionVersion() {},
+ },
+};
diff --git a/app/assets/javascripts/work_items/notes/collapse_utils.js b/app/assets/javascripts/work_items/notes/collapse_utils.js
new file mode 100644
index 00000000000..db7b4530e2a
--- /dev/null
+++ b/app/assets/javascripts/work_items/notes/collapse_utils.js
@@ -0,0 +1,92 @@
+import { DESCRIPTION_TYPE, TIME_DIFFERENCE_VALUE } from '~/notes/constants';
+
+/**
+ * Checks the time difference between two notes from their 'created_at' dates
+ * returns an integer
+ */
+export const getTimeDifferenceInMinutes = (noteBeginning, noteEnd) => {
+ const descriptionNoteBegin = new Date(noteBeginning.createdAt);
+ const descriptionNoteEnd = new Date(noteEnd.createdAt);
+ const timeDifferenceMinutes = (descriptionNoteEnd - descriptionNoteBegin) / 1000 / 60;
+
+ return Math.ceil(timeDifferenceMinutes);
+};
+
+/**
+ * Checks if a note is a system note and if the content is description
+ *
+ * @param {Object} note
+ * @returns {Boolean}
+ */
+export const isDescriptionSystemNote = (note) => {
+ return note.system && note.body === DESCRIPTION_TYPE;
+};
+
+/**
+ * Collapses the system notes of a description type, e.g. Changed the description, n minutes ago
+ * the notes will collapse as long as they happen no more than 10 minutes away from each away
+ * in between the notes can be anything, another type of system note
+ * (such as 'changed the weight') or a comment.
+ *
+ * @param {Array} notes
+ * @returns {Array}
+ */
+export const collapseSystemNotes = (notes) => {
+ let lastDescriptionSystemNote = null;
+ let lastDescriptionSystemNoteIndex = -1;
+
+ return notes.reduce((acc, currentNote) => {
+ const note = currentNote.notes.nodes[0];
+ let lastStartVersionId = '';
+
+ if (isDescriptionSystemNote(note)) {
+ // is it the first one?
+ if (!lastDescriptionSystemNote) {
+ lastDescriptionSystemNote = note;
+ } else {
+ const timeDifferenceMinutes = getTimeDifferenceInMinutes(lastDescriptionSystemNote, note);
+
+ // are they less than 10 minutes apart from the same user?
+ if (
+ timeDifferenceMinutes > TIME_DIFFERENCE_VALUE ||
+ note.author.id !== lastDescriptionSystemNote.author.id ||
+ lastDescriptionSystemNote.systemNoteMetadata.descriptionVersion?.deleted
+ ) {
+ // update the previous system note
+ lastDescriptionSystemNote = note;
+ } else {
+ // set the first version to fetch grouped system note versions
+
+ lastStartVersionId = lastDescriptionSystemNote.systemNoteMetadata.descriptionVersion.id;
+
+ // delete the previous one
+ acc.splice(lastDescriptionSystemNoteIndex, 1);
+ }
+ }
+
+ // update the previous system note index
+ lastDescriptionSystemNoteIndex = acc.length;
+
+ acc.push({
+ notes: {
+ nodes: [
+ {
+ ...note,
+ systemNoteMetadata: {
+ ...note.systemNoteMetadata,
+ descriptionVersion: {
+ ...note.systemNoteMetadata.descriptionVersion,
+ startVersionId: lastStartVersionId,
+ },
+ },
+ },
+ ],
+ },
+ });
+ } else {
+ acc.push(currentNote);
+ }
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 4f8c720eb1f..60503add119 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,7 +1,5 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
import ZenMode from '~/zen_mode';
import WorkItemDetail from '../components/work_item_detail.vue';
@@ -19,7 +17,7 @@ export default {
},
inject: ['issuesListPath'],
props: {
- id: {
+ iid: {
type: String,
required: true,
},
@@ -29,11 +27,6 @@ export default {
error: '',
};
},
- computed: {
- gid() {
- return convertToGraphQLId(TYPENAME_WORK_ITEM, this.id);
- },
- },
mounted() {
this.ZenMode = new ZenMode();
},
@@ -70,10 +63,6 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert>
- <work-item-detail
- :work-item-id="gid"
- :work-item-iid="id"
- @deleteWorkItem="deleteWorkItem($event)"
- />
+ <work-item-detail :work-item-iid="iid" @deleteWorkItem="deleteWorkItem($event)" />
</div>
</template>
diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js
index 1e3a7e184bb..664f3dcc8f7 100644
--- a/app/assets/javascripts/work_items/router/routes.js
+++ b/app/assets/javascripts/work_items/router/routes.js
@@ -1,7 +1,7 @@
function getRoutes() {
const routes = [
{
- path: '/:id',
+ path: '/:iid',
name: 'workItem',
component: () => import('../pages/work_item_root.vue'),
props: true,
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index 653819904af..13fc521464f 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -9,18 +9,12 @@ import {
WORK_ITEM_TYPENAME,
WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
} from '~/work_items/constants';
-import workItemQuery from './graphql/work_item.query.graphql';
-import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
-
-export function getWorkItemQuery(isFetchedByIid) {
- return isFetchedByIid ? workItemByIidQuery : workItemQuery;
-}
export const findHierarchyWidgets = (widgets) =>
widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
export const findHierarchyWidgetChildren = (workItem) =>
- findHierarchyWidgets(workItem.widgets).children.nodes;
+ findHierarchyWidgets(workItem?.widgets)?.children.nodes;
const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => {
return `${
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 4e3fb819f4c..2ed955a56b6 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -8,7 +8,9 @@
background-color: transparent;
}
- &:not(.ProseMirror-hideselection) .content-editor-selection {
+ &:not(.ProseMirror-hideselection) .content-editor-selection,
+ a.ProseMirror-selectednode,
+ span.ProseMirror-selectednode {
background-color: $blue-100;
box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100;
}
@@ -31,6 +33,17 @@
outline-offset: -3px;
}
+ .selectedCell::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba($blue-200, 0.25);
+ pointer-events: none;
+ }
+
video {
max-width: 400px;
}
@@ -43,17 +56,15 @@
list-style: none;
padding: 0;
- li {
- margin: 0 !important;
- }
- }
-
- [data-type='taskList'] {
+ ul,
p {
margin-bottom: 0;
}
- li {
+ > li {
+ display: flex;
+ margin: 0;
+
> label,
> div {
display: inline-block;
@@ -113,6 +124,15 @@
display: inherit;
}
}
+
+ .gl-new-dropdown-inner li {
+ margin-left: 0 !important;
+
+ &.gl-new-dropdown-item {
+ padding-left: $gl-spacing-scale-2;
+ padding-right: $gl-spacing-scale-2;
+ }
+ }
}
.table-creator-grid-item {
@@ -155,8 +175,10 @@
}
}
-
+.content-editor-table-dropdown .gl-new-dropdown-panel {
+ min-width: auto;
+}
.bubble-menu-form {
- width: 320px;
+ min-width: 320px;
}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 35c619a2e2f..f8160c04031 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,5 +1,4 @@
.whats-new-drawer {
- margin-top: calc(#{$header-height} + #{$calc-application-bars-height});
@include gl-shadow-none;
overflow-y: hidden;
width: 500px;
diff --git a/app/assets/stylesheets/fonts.scss b/app/assets/stylesheets/fonts.scss
index a023b41083d..bc49d17fcbb 100644
--- a/app/assets/stylesheets/fonts.scss
+++ b/app/assets/stylesheets/fonts.scss
@@ -14,8 +14,33 @@ Usage:
}
/* -------------------------------------------------------
+Monospaced font: GitLab Mono.
+
+Usage:
+ html { font-family: 'GitLab Mono', sans-serif; }
+*/
+@font-face {
+ font-family: 'GitLab Mono';
+ font-weight: 100 900;
+ font-display: optional;
+ font-style: normal;
+ src: font-url('gitlab-mono/GitLabMono.woff2') format('woff2');
+}
+
+@font-face {
+ font-family: 'GitLab Mono';
+ font-weight: 100 900;
+ font-display: optional;
+ font-style: italic;
+ src: font-url('gitlab-mono/GitLabMono-Italic.woff2') format('woff2');
+}
+
+/* -------------------------------------------------------
Monospaced font: JetBrains Mono.
+All of the definitions below can be removed once
+`GitLab Mono` is properly rolled out.
+
Usage:
html { font-family: 'JetBrains Mono', sans-serif; }
*/
@@ -49,11 +74,6 @@ Usage:
src: font-url('jetbrains-mono/JetBrainsMono-BoldItalic.woff2') format('woff2');
}
-:root {
- --default-mono-font: 'JetBrains Mono', 'Menlo';
- --default-regular-font: 'GitLab Sans', -apple-system;
-}
-
// This isn't the best solution, but we needed a quick fix
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107592/
* {
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index a0bfca79dc3..f81371828f2 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -46,3 +46,7 @@
min-height: 34px;
}
}
+
+.gl-broadcast-message-content p:last-child {
+ margin: 0;
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index f828129cdf1..2ec7c891197 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -8,6 +8,10 @@
--application-bar-left: 0px;
--application-bar-right: 0px;
+
+ @each $name, $size in $grid-breakpoints {
+ --breakpoint-#{$name}: #{$size};
+ }
}
.with-performance-bar {
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 6c40781670a..192cb82aaab 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -1,6 +1,6 @@
// Common
.diff-file {
- margin-bottom: $gl-padding;
+ padding-bottom: $gl-padding;
&.has-body {
.file-title {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 503e22742ba..2e88b45d646 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -3,6 +3,7 @@
*
*/
.file-holder {
+ background: $white;
border: 1px solid $border-color;
border-radius: $border-radius-default;
@@ -99,8 +100,6 @@
}
.file-content {
- background: $white;
-
&.image_file,
&.audio,
&.video {
@@ -246,7 +245,6 @@ span.idiff {
justify-content: space-between;
background-color: $gray-light;
border-bottom: 1px solid $border-color;
- border-top: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
@@ -472,6 +470,8 @@ span.idiff {
}
.mr-tree-list:not(.tree-list-blobs) {
+ overflow: hidden;
+
.tree-list-parent::before {
@include gl-content-empty;
@include gl-absolute;
@@ -514,7 +514,6 @@ span.idiff {
}
.blame-commit {
- padding: 5px 10px;
width: 400px;
flex: none;
background: $gray-light;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 104cdf5544d..b78b07f953b 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -439,20 +439,11 @@
.vue-filtered-search-bar-container {
.gl-search-box-by-click {
- // Absolute width is needed to prevent flex to grow
- // beyond the available width.
- .gl-filtered-search-scrollable {
- width: 1px;
- }
+ // This enforces width of flex items to be
+ // calculated in advance so that content
+ // does not overflow.
- // There are several styling issues happening while using
- // `GlFilteredSearch` in roadmap due to some of our global
- // styles which we need to override until those are fixed
- // at framework level.
- // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/908
- .input-group-prepend + .gl-filtered-search-scrollable {
- border-radius: 0;
- }
+ min-width: 0;
}
.sort-dropdown-container {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 0c53b3fd866..b2ba1d8830d 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -76,7 +76,7 @@ $search-input-field-x-min-width: 200px;
}
}
- .header-search {
+ .header-search-form {
min-width: $search-input-field-min-width;
// This is a temporary workaround!
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 23dbe440d33..7dfbd5485d8 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -36,12 +36,13 @@ body {
}
.layout-page {
- padding-top: $calc-application-header-height;
+ padding-top: calc(#{$header-height} + #{$calc-application-bars-height});
padding-bottom: $calc-application-footer-height;
}
.content-wrapper {
- padding-bottom: 100px;
+ padding-top: var(--top-bar-height);
+ padding-bottom: $content-wrapper-padding;
}
.container {
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index e57dad9e4cb..5fdab7891ec 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -105,6 +105,7 @@
padding: 5px;
box-shadow: none;
width: 100%;
+ resize: none !important;
}
.md-suggestion-diff {
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 15a31fbb3d9..529f6acaf04 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -156,6 +156,12 @@
background: linear-gradient(to $gradient-direction,
$gradient-color 45%,
rgba($gradient-color, 0.4));
+ border: 0;
+ padding: 0;
+
+ &:hover {
+ @include gl-focus;
+ }
&.scrolling {
visibility: visible;
@@ -164,8 +170,8 @@
}
svg {
- position: relative;
- top: 5px;
+ position: absolute;
+ top: 12px;
font-size: 18px;
}
}
@@ -430,8 +436,7 @@
&:last-child {
&::after {
- content: '';
- padding: 0;
+ display: none;
}
}
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 9fdf889f4e9..b7a674a35e7 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -489,14 +489,12 @@
padding: 0;
.issuable-context-form {
- $issue-sticky-header-height: 76px;
-
- top: calc(#{$calc-application-header-height} + #{$issue-sticky-header-height});
- height: calc(#{$calc-application-viewport-height} - #{$issue-sticky-header-height} - var(--mr-review-bar-height));
+ top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height});
+ height: calc(#{$calc-application-viewport-height} - #{$mr-sticky-header-height} - var(--mr-review-bar-height));
position: sticky;
overflow: auto;
padding: 0 15px;
- margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1);
+ margin-bottom: calc((#{$content-wrapper-padding} * -1) + var(--mr-review-bar-height));
}
}
}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 699693bd354..a3b238d657d 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -40,8 +40,9 @@
&:target,
&.target {
- .timeline-content {
- background: $line-target-blue !important;
+ .timeline-content,
+ + .public-note.discussion-reply-holder {
+ background-color: $line-target-blue !important;
}
&.system-note .note-body .note-text.system-note-commit-list::after {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1ba3de68662..f77804fb7fc 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -460,6 +460,7 @@ $browser-scrollbar-size: 10px;
* Misc
*/
$header-height: var(--header-height, 48px);
+$content-wrapper-padding: 100px;
$header-zindex: 1000;
$zindex-dropdown-menu: 300;
$ide-statusbar-height: 25px;
@@ -568,9 +569,9 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
/*
* Fonts
*/
-$monospace-font: var(--default-mono-font, 'Menlo'), 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas',
+$monospace-font: 'GitLab Mono', 'JetBrains Mono', 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas',
'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
-$regular-font: var(--default-regular-font, -apple-system), BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans',
+$regular-font: 'GitLab Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans',
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
$gl-monospace-font: $monospace-font;
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 02469cf5165..9ad7c1b796c 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -134,10 +134,6 @@ $dark-il: #de935f;
// Line numbers
- .file-line-num {
- @include line-link($white, 'link');
- }
-
.file-line-blame {
@include line-link($white, 'git');
}
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 30d04b4002e..b1d89d3c253 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -125,10 +125,6 @@ $monokai-gh: #75715e;
@include hljs-override('params', $monokai-nb);
// Line numbers
- .file-line-num {
- @include line-link($white, 'link');
- }
-
.file-line-blame {
@include line-link($white, 'git');
}
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 8339d7eff80..4762aae1d12 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -24,10 +24,6 @@
}
// Line numbers
- .file-line-num {
- @include line-link($black, 'link');
- }
-
.file-line-blame {
@include line-link($black, 'git');
}
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 075510e6e5f..7958959bfc3 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -128,10 +128,6 @@ $solarized-dark-il: #2aa198;
@include hljs-override('params', $solarized-dark-nb);
// Line numbers
- .file-line-num {
- @include line-link($white, 'link');
- }
-
.file-line-blame {
@include line-link($white, 'git');
}
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 4e244ed7420..f156077c64d 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -118,10 +118,6 @@ $solarized-light-il: #2aa198;
@include hljs-override('params', $solarized-light-nb);
// Line numbers
- .file-line-num {
- @include line-link($black, 'link');
- }
-
.file-line-blame {
@include line-link($black, 'git');
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 969a6665634..14524e163b2 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -94,10 +94,6 @@ $white-gc-bg: #eaf2f5;
}
// Line numbers
-.file-line-num {
- @include line-link($black, 'link');
-}
-
.file-line-blame {
@include line-link($black, 'git');
}
diff --git a/app/assets/stylesheets/notify_enhanced.scss b/app/assets/stylesheets/notify_enhanced.scss
index b331d997a97..a3e02dabe0e 100644
--- a/app/assets/stylesheets/notify_enhanced.scss
+++ b/app/assets/stylesheets/notify_enhanced.scss
@@ -32,6 +32,10 @@ body {
font-size: inherit;
}
+pre {
+ font-size: 14px;
+}
+
.gl-mb-5 {
@include gl-mb-5;
}
diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
index ed2707ffbcd..7abde7c1a11 100644
--- a/app/assets/stylesheets/page_bundles/alert_management_settings.scss
+++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
@@ -3,21 +3,13 @@
$stroke-size: 1px;
.right-arrow {
- @include gl-relative;
- @include gl-w-full;
height: $stroke-size;
- background-color: var(--gray-400, $gray-400);
min-width: $gl-spacing-scale-5;
&-head {
- @include gl-absolute;
top: -$gl-spacing-scale-2;
left: calc(100% - #{$gl-spacing-scale-3} - #{2 * $stroke-size});
- border-color: var(--gray-400, $gray-400);
- @include gl-border-solid;
border-width: 0 $stroke-size $stroke-size 0;
- @include gl-display-inline-block;
- @include gl-p-2;
transform: rotate(-45deg);
}
}
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
index b42e6fd85fa..c19561a5e5e 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -29,7 +29,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
.design-list-item {
- height: 280px;
+ height: 160px;
text-decoration: none;
.icon-version-status {
@@ -37,15 +37,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
right: 10px;
top: 10px;
}
-
- .card-body {
- height: 230px;
- }
-}
-
-// This is temporary class to be removed after feature flag removal: https://gitlab.com/gitlab-org/gitlab/-/issues/223197
-.design-list-item-new {
- height: 210px;
}
.design-note-pin {
@@ -147,7 +138,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
.design-note-pin {
- margin-left: $gl-padding;
+ margin-left: 9px;
}
.design-discussion {
@@ -157,13 +148,13 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
content: '';
border-left: 1px solid var(--gray-100, $gray-100);
position: absolute;
- left: 28px;
+ left: 22px;
top: -17px;
height: 17px;
}
.design-note {
- padding: $gl-padding;
+ padding: $gl-padding-8;
list-style: none;
transition: background $gl-transition-duration-medium $general-hover-transition-curve;
border-top-left-radius: $border-radius-default; // same border radius used by .bordered-box
@@ -179,7 +170,9 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
}
.reply-wrapper {
- padding: $gl-padding;
+ padding: $gl-padding-8 $gl-padding-8 $gl-padding-4;
+ background: $gray-10;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
}
}
diff --git a/app/assets/stylesheets/page_bundles/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index 9e9723d2e5a..55fffad4a0e 100644
--- a/app/assets/stylesheets/page_bundles/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -1,15 +1,6 @@
@import 'page_bundles/mixins_and_variables_and_functions';
.file-editor {
- .nav-links {
- border-top: 1px solid var(--border-color, $border-color);
- border-right: 1px solid var(--border-color, $border-color);
- border-left: 1px solid var(--border-color, $border-color);
- border-bottom: 0;
- border-radius: $border-radius-small $border-radius-small 0 0;
- background: var(--gray-50, $gray-50);
- }
-
#editor,
.editor {
@include gl-border-0;
@@ -110,11 +101,13 @@
.file-buttons {
display: flex;
- flex-direction: column;
+ flex-direction: row;
+ justify-content: space-between;
width: 100%;
+ padding: $gl-padding-8 0 0;
.md-header-toolbar {
- margin: $gl-padding 0;
+ margin-left: 0;
}
.soft-wrap-toggle {
@@ -129,6 +122,17 @@
}
}
+@include media-breakpoint-down(sm) {
+ .file-editor .file-buttons {
+ flex-direction: column;
+ padding: 0;
+
+ .md-header-toolbar {
+ margin: $gl-padding-8 0;
+ }
+ }
+}
+
.blob-new-page-title,
.blob-edit-page-title {
margin: 19px 0 21px;
@@ -166,8 +170,7 @@
.license-selector,
.gitignore-selector,
.gitlab-ci-yml-selector,
- .dockerfile-selector,
- .metrics-dashboard-selector {
+ .dockerfile-selector {
display: inline-block;
vertical-align: top;
font-family: $regular_font;
diff --git a/app/assets/stylesheets/page_bundles/error_tracking_details.scss b/app/assets/stylesheets/page_bundles/error_tracking_details.scss
index a47c5cc9b3e..9b93fa7f6d8 100644
--- a/app/assets/stylesheets/page_bundles/error_tracking_details.scss
+++ b/app/assets/stylesheets/page_bundles/error_tracking_details.scss
@@ -1,36 +1,5 @@
@import 'page_bundles/mixins_and_variables_and_functions';
-.error-details {
- li {
- @include gl-line-height-32;
- }
-
- .btn-outline-info {
- color: var(--blue-500, $blue-500);
- border-color: var(--blue-500, $blue-500);
- }
-
- .error-details-header {
- border-bottom: 1px solid var(--border-color, $border-color);
-
- @include media-breakpoint-down(xs) {
- flex-flow: column;
-
- .error-details-meta-culprit {
- display: flex;
- }
-
- .error-details-options {
- width: 100%;
-
- .dropdown-toggle {
- text-align: center;
- }
- }
- }
- }
-}
-
.stacktrace {
.file-title {
svg {
diff --git a/app/assets/stylesheets/page_bundles/error_tracking_index.scss b/app/assets/stylesheets/page_bundles/error_tracking_index.scss
index 5c49bcc0348..4baab693aed 100644
--- a/app/assets/stylesheets/page_bundles/error_tracking_index.scss
+++ b/app/assets/stylesheets/page_bundles/error_tracking_index.scss
@@ -1,29 +1,13 @@
@import 'page_bundles/mixins_and_variables_and_functions';
.error-list {
- .dropdown {
- min-width: auto;
- }
-
.filtered-search-box .form-control {
min-width: unset;
}
- .sort-control {
- .btn {
- padding-right: 2rem;
- }
-
- .gl-dropdown-caret {
- position: absolute;
- right: 0.5rem;
- top: 0.5rem;
- }
- }
-
@include media-breakpoint-down(sm) {
.error-list-table {
- .table-col {
+ td {
min-height: 68px;
&:last-child {
diff --git a/app/assets/stylesheets/page_bundles/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index 98fa45e0e3d..355d2afc0ba 100644
--- a/app/assets/stylesheets/page_bundles/login.scss
+++ b/app/assets/stylesheets/page_bundles/login.scss
@@ -55,12 +55,12 @@
.omniauth-container {
box-shadow: none;
}
+ }
- .g-recaptcha {
- > div {
- margin-left: auto;
- margin-right: auto;
- }
+ .g-recaptcha {
+ > div {
+ margin-left: auto;
+ margin-right: auto;
}
}
@@ -103,6 +103,10 @@
.username .validation-error {
color: $red-500;
}
+
+ .terms .gl-form-checkbox {
+ @include gl-reset-font-size;
+ }
}
}
@@ -192,13 +196,6 @@
}
}
- .form-control {
- &:active,
- &:focus {
- background-color: $white;
- }
- }
-
.submit-container {
margin-top: 16px;
}
@@ -267,7 +264,7 @@
left: 0;
right: 0;
height: 40px;
- background: $white;
+ background: var(--white, $white);
}
.login-page-broadcast {
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 61f8f0de557..fc4a9d3dff9 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -301,10 +301,6 @@ $tabs-holder-z-index: 250;
}
.tree-list-icon {
- top: 50%;
- left: 10px;
- transform: translateY(-50%);
-
&,
svg {
fill: var(--gray-400, $gray-400);
@@ -327,15 +323,18 @@ $tabs-holder-z-index: 250;
.diffs .files {
.diff-tree-list {
position: relative;
+ // height is fully handled on the javascript side in narrow view
+ min-height: 0;
+ height: auto;
top: 0;
// !important is required to override inline styles of resizable sidebar
width: 100% !important;
// avoid sticky elements overlap header and other elements
z-index: 1;
+ @include gl-mb-3;
}
.tree-list-holder {
- max-height: calc(50px + 50vh);
padding-right: 0;
}
}
@@ -550,7 +549,8 @@ $tabs-holder-z-index: 250;
border-radius: $border-radius-default;
}
- .mr-widget-section:not(:first-child) > div {
+ .mr-widget-section:not(:first-child) > div,
+ .mr-widget-section .mr-widget-section > div {
border-top: solid 1px var(--border-color, $border-color);
}
@@ -1271,3 +1271,32 @@ $tabs-holder-z-index: 250;
margin-right: 8px;
border: 2px solid var(--gray-50, $gray-50);
}
+
+.diff-file-discussions-wrapper {
+ @include gl-w-full;
+
+ max-width: 800px;
+
+ .diff-discussions > .notes {
+ @include gl-p-5;
+ }
+
+ .diff-discussions:not(:first-child) >.notes {
+ @include gl-pt-0;
+ }
+
+ .note-discussion {
+ @include gl-rounded-base;
+
+ border: 1px solid var(--gray-100, $gray-100) !important;
+ }
+
+ .discussion-collapsible {
+ @include gl-m-0;
+ @include gl-border-l-0;
+ @include gl-border-r-0;
+ @include gl-border-b-0;
+ @include gl-rounded-top-left-none;
+ @include gl-rounded-top-right-none;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index 51bffd99dd0..10cc6cbd78e 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -4,17 +4,6 @@
box-shadow: inset 0 0 0 $gl-border-size-1 $red-500 if-important($important);
}
-.timezone-dropdown {
- .gl-dropdown-item-text-primary {
- @include gl-overflow-hidden;
- @include gl-text-overflow-ellipsis;
- }
-
- .btn-block {
- margin-bottom: 0;
- }
-}
-
.modal-footer {
@include gl-bg-gray-10;
}
@@ -52,65 +41,17 @@ $scroll-top-gradient: linear-gradient(to bottom, $gradient-dark-gray 0%, $gradie
$scroll-bottom-gradient: linear-gradient(to bottom, $gradient-gray 0%, $gradient-dark-gray 100%);
$column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradient-gray 100%);
-.schedule-shell {
- @include gl-relative;
- @include gl-h-full;
- @include gl-w-full;
- @include gl-overflow-x-auto;
-}
-
.timeline-section {
- @include gl-sticky;
- @include gl-top-0;
z-index: 20;
- .timeline-header-label,
- .timeline-header-item {
- @include gl-float-left;
- }
-
.timeline-header-label {
- @include gl-sticky;
- @include gl-top-0;
- @include gl-left-0;
width: $details-cell-width;
- z-index: 2;
}
.timeline-header-item {
- .item-sublabel .sublabel-value {
- color: var(--gray-700, $gray-700);
- @include gl-font-weight-normal;
-
- &.label-dark {
- color: var(--gray-900, $gray-900);
- }
-
- &.label-bold {
- @include gl-font-weight-bold;
- }
- }
-
- .item-sublabel {
- @include gl-relative;
- @include gl-display-flex;
-
- .sublabel-value {
- @include gl-flex-grow-1;
- @include gl-flex-basis-0;
-
- text-align: center;
- @include gl-font-base;
- }
- }
-
.current-day-indicator-header {
- @include gl-absolute;
- @include gl-bottom-0;
height: $grid-size;
width: $grid-size;
- background-color: var(--red-500, $red-500);
- @include gl-rounded-full;
transform: translate(-50%, 50%);
}
@@ -137,35 +78,19 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
.details-cell,
.timeline-cell {
- @include gl-float-left;
height: $item-height;
}
.details-cell {
- @include gl-sticky;
- @include gl-left-0;
width: $details-cell-width;
- @include gl-font-base;
z-index: 10;
}
.timeline-cell {
- @include gl-relative;
- @include gl-bg-transparent;
- border-right: $border-style;
-
- &:last-child {
- @include gl-border-r-0;
- }
-
.current-day-indicator {
- @include gl-absolute;
top: -1px;
width: $gl-spacing-scale-1;
height: calc(100% + 1px);
- background-color: var(--red-500, $red-500);
- @include gl-pointer-events-none;
- transform: translateX(-50%);
}
}
diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss
index d37e87b5cd5..d1d14cbcddd 100644
--- a/app/assets/stylesheets/page_bundles/search.scss
+++ b/app/assets/stylesheets/page_bundles/search.scss
@@ -4,11 +4,8 @@ $search-dropdown-max-height: 400px;
$search-avatar-size: 16px;
$search-sidebar-min-width: 240px;
$search-sidebar-max-width: 300px;
-$search-keyboard-shortcut: '/';
$language-filter-max-height: 20rem;
-$border-radius-medium: 3px;
-
.search-results {
.search-result-row {
border-bottom: 1px solid var(--border-color, $border-color);
@@ -21,6 +18,13 @@ $border-radius-medium: 3px;
}
}
+.hr-x {
+ margin-left: -$gl-spacing-scale-5;
+ margin-right: -$gl-spacing-scale-5;
+ margin-top: $gl-spacing-scale-3;
+ margin-bottom: $gl-spacing-scale-5;
+}
+
.language-filter-checkbox {
.custom-control-label {
flex-grow: 1;
@@ -28,8 +32,17 @@ $border-radius-medium: 3px;
}
.search-sidebar {
- @include media-breakpoint-up(md) {
+ @include media-breakpoint-down(lg) {
+ max-width: 100%;
+ }
+
+ @include media-breakpoint-down(xl) {
min-width: $search-sidebar-min-width;
+ max-width: $search-sidebar-min-width;
+ }
+
+ @include media-breakpoint-up(xl) {
+ min-width: $search-sidebar-max-width;
max-width: $search-sidebar-max-width;
}
@@ -38,6 +51,44 @@ $border-radius-medium: 3px;
}
}
+.issue-filters {
+ .label-filter {
+ list-style: none;
+
+ .header-search-dropdown-menu {
+ max-height: $language-filter-max-height;
+
+ @include media-breakpoint-down(xl) {
+ min-width: calc(#{$search-sidebar-min-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5}));
+ max-width: calc(#{$search-sidebar-min-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5}));
+ }
+
+ @include media-breakpoint-up(xl) {
+ min-width: calc(#{$search-sidebar-max-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5}));
+ max-width: calc(#{$search-sidebar-max-width} - (#{$gl-spacing-scale-5} + #{$gl-spacing-scale-5}));
+ }
+
+ .label-with-color-checkbox {
+ max-height: $gl-spacing-scale-5;
+
+ .custom-control-label {
+ margin-bottom: 0;
+ max-height: $gl-spacing-scale-5;
+
+ .label-title {
+ margin-left: -$gl-spacing-scale-2;
+ }
+ }
+ }
+ }
+ }
+}
+
+.advanced-search-promote {
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
.search-max-w-inherit {
max-width: inherit;
}
diff --git a/app/assets/stylesheets/page_bundles/web_ide_loader.scss b/app/assets/stylesheets/page_bundles/web_ide_loader.scss
new file mode 100644
index 00000000000..f922cadc235
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/web_ide_loader.scss
@@ -0,0 +1,38 @@
+.web-ide-loader {
+ max-width: 400px;
+}
+
+.web-ide-loader .tanuki-logo {
+ width: 50px;
+ height: 50px;
+}
+
+.web-ide-loader .tanuki,
+.web-ide-loader .right-cheek,
+.web-ide-loader .chin,
+.web-ide-loader .left-cheek {
+ animation: animate-tanuki 1.5s infinite;
+}
+
+.web-ide-loader .right-cheek {
+ animation-delay: 0.35s;
+}
+
+.web-ide-loader .chin {
+ animation-delay: 0.7s;
+}
+
+.web-ide-loader .left-cheek {
+ animation-delay: 1.05s;
+}
+
+@keyframes animate-tanuki {
+ 0%,
+ 50% {
+ filter: brightness(1) grayscale(0);
+ }
+
+ 25% {
+ filter: brightness(1.2) grayscale(0.2);
+ }
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index d029aa01e37..322363d7f4b 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -246,7 +246,7 @@ table {
.commit-diff {
.discussion-reply-holder {
background-color: $gray-light;
- border-radius: 0 0 3px 3px;
+ border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base;
padding: $gl-padding;
border-top: 1px solid $gray-50;
@@ -257,6 +257,12 @@ table {
&.is-replying {
padding-bottom: $gl-padding;
+ background-color: $white;
+ }
+
+ &.internal-note,
+ &.internal-note.is-replying {
+ background-color: $orange-50;
}
.user-avatar-link {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index b31ee069236..c5b644bd72f 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -44,7 +44,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
background: var(--gray-50, $gray-50);
}
- .timeline-entry:last-child::before {
+ .timeline-entry:not(.draft-note):last-child::before {
background: var(--white);
.gl-dark & {
@@ -667,7 +667,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.discussion-reply-holder {
border-top: 0;
- border-radius: 0 0 $gl-border-radius-base $gl-border-radius-base;
+ border-radius: $gl-border-radius-base $gl-border-radius-base;
position: relative;
.discussion-form {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index d26e29c4047..8f52422b4b8 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -63,12 +63,6 @@
}
}
- @include media-breakpoint-down(md) {
- .time-ago {
- align-items: flex-end;
- }
- }
-
.duration,
.finished-at {
color: $gl-text-color-secondary;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 74ffebd44ec..7be15c2d8f9 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -2,6 +2,9 @@
// Please see the feedback issue for more details and help:
// https://gitlab.com/gitlab-org/gitlab/-/issues/331812
@charset "UTF-8";
+:root {
+ --white: #333238;
+}
*,
*::before,
*::after {
@@ -17,10 +20,9 @@ header {
}
body {
margin: 0;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -48,7 +50,7 @@ a:not([href]):not([class]) {
text-decoration: none;
}
kbd {
- font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono",
+ font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono",
"Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono",
"lucida console", monospace;
font-size: 1em;
@@ -413,10 +415,9 @@ a.gl-badge.badge-warning:active {
.gl-form-input,
.gl-form-input.form-control {
background-color: #333238;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
@@ -581,8 +582,7 @@ html {
.layout-page {
padding-top: calc(
var(--header-height, 48px) +
- calc(var(--system-header-height) + var(--performance-bar-height)) +
- var(--top-bar-height)
+ calc(var(--system-header-height) + var(--performance-bar-height))
);
padding-bottom: var(--system-footer-height);
}
@@ -631,6 +631,11 @@ html {
--top-bar-height: 0px;
--system-footer-height: 0px;
--mr-review-bar-height: 0px;
+ --breakpoint-xs: 0;
+ --breakpoint-sm: 576px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 992px;
+ --breakpoint-xl: 1200px;
}
.with-top-bar {
--top-bar-height: 48px;
@@ -822,15 +827,15 @@ kbd {
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
-.navbar-gitlab .header-search {
+.navbar-gitlab .header-search-form {
min-width: 320px;
}
@media (min-width: 768px) and (max-width: 1199.98px) {
- .navbar-gitlab .header-search {
+ .navbar-gitlab .header-search-form {
min-width: 200px;
}
}
-.navbar-gitlab .header-search .keyboard-shortcut-helper {
+.navbar-gitlab .header-search-form .keyboard-shortcut-helper {
transform: translateY(calc(50% - 2px));
box-shadow: none;
border-color: transparent;
@@ -1716,7 +1721,7 @@ body.gl-dark .navbar-gitlab .navbar-sub-nav {
body.gl-dark .navbar-gitlab .nav > li {
color: #ececef;
}
-body.gl-dark .navbar-gitlab .nav > li.header-search-new {
+body.gl-dark .navbar-gitlab .nav > li.header-search {
color: #ececef;
}
body.gl-dark .navbar-gitlab .nav > li > a .notification-dot {
@@ -1753,25 +1758,25 @@ body.gl-dark
.notification-dot {
background-color: #ececef;
}
-body.gl-dark .header-search {
+body.gl-dark .header-search-form {
background-color: rgba(236, 236, 239, 0.2) !important;
border-radius: 4px;
}
-body.gl-dark .header-search svg.gl-search-box-by-type-search-icon {
+body.gl-dark .header-search-form svg.gl-search-box-by-type-search-icon {
color: rgba(236, 236, 239, 0.8);
}
-body.gl-dark .header-search input {
+body.gl-dark .header-search-form input {
background-color: transparent;
color: rgba(236, 236, 239, 0.8);
box-shadow: inset 0 0 0 1px rgba(236, 236, 239, 0.4);
}
-body.gl-dark .header-search input::placeholder {
+body.gl-dark .header-search-form input::placeholder {
color: rgba(236, 236, 239, 0.8);
}
-body.gl-dark .header-search input:active::placeholder {
+body.gl-dark .header-search-form input:active::placeholder {
color: #737278;
}
-body.gl-dark .header-search .keyboard-shortcut-helper {
+body.gl-dark .header-search-form .keyboard-shortcut-helper {
color: #ececef;
background-color: rgba(236, 236, 239, 0.2);
}
@@ -1795,11 +1800,11 @@ body.gl-dark .navbar-gitlab .navbar-nav li.active > button {
color: var(--gl-text-color);
background-color: var(--gray-200);
}
-body.gl-dark .navbar-gitlab .header-search {
+body.gl-dark .navbar-gitlab .header-search-form {
background-color: var(--gray-100) !important;
box-shadow: inset 0 0 0 1px var(--border-color) !important;
}
-body.gl-dark .navbar-gitlab .header-search:active {
+body.gl-dark .navbar-gitlab .header-search-form:active {
background-color: var(--gray-100) !important;
box-shadow: inset 0 0 0 1px var(--blue-200) !important;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index c5a5d1aa289..65500800ce3 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -2,6 +2,9 @@
// Please see the feedback issue for more details and help:
// https://gitlab.com/gitlab-org/gitlab/-/issues/331812
@charset "UTF-8";
+:root {
+ --white: #fff;
+}
*,
*::before,
*::after {
@@ -17,10 +20,9 @@ header {
}
body {
margin: 0;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -48,7 +50,7 @@ a:not([href]):not([class]) {
text-decoration: none;
}
kbd {
- font-family: var(--default-mono-font, "Menlo"), "DejaVu Sans Mono",
+ font-family: "GitLab Mono", "JetBrains Mono", "Menlo", "DejaVu Sans Mono",
"Liberation Mono", "Consolas", "Ubuntu Mono", "Courier New", "andale mono",
"lucida console", monospace;
font-size: 1em;
@@ -413,10 +415,9 @@ a.gl-badge.badge-warning:active {
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
@@ -581,8 +582,7 @@ html {
.layout-page {
padding-top: calc(
var(--header-height, 48px) +
- calc(var(--system-header-height) + var(--performance-bar-height)) +
- var(--top-bar-height)
+ calc(var(--system-header-height) + var(--performance-bar-height))
);
padding-bottom: var(--system-footer-height);
}
@@ -631,6 +631,11 @@ html {
--top-bar-height: 0px;
--system-footer-height: 0px;
--mr-review-bar-height: 0px;
+ --breakpoint-xs: 0;
+ --breakpoint-sm: 576px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 992px;
+ --breakpoint-xl: 1200px;
}
.with-top-bar {
--top-bar-height: 48px;
@@ -822,15 +827,15 @@ kbd {
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
-.navbar-gitlab .header-search {
+.navbar-gitlab .header-search-form {
min-width: 320px;
}
@media (min-width: 768px) and (max-width: 1199.98px) {
- .navbar-gitlab .header-search {
+ .navbar-gitlab .header-search-form {
min-width: 200px;
}
}
-.navbar-gitlab .header-search .keyboard-shortcut-helper {
+.navbar-gitlab .header-search-form .keyboard-shortcut-helper {
transform: translateY(calc(50% - 2px));
box-shadow: none;
border-color: transparent;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index f676782de2a..40e1e4b1996 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -2,6 +2,9 @@
// Please see the feedback issue for more details and help:
// https://gitlab.com/gitlab-org/gitlab/-/issues/331812
@charset "UTF-8";
+:root {
+ --white: #fff;
+}
*,
*::before,
*::after {
@@ -16,10 +19,9 @@ header {
}
body {
margin: 0;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
@@ -79,16 +81,11 @@ input {
button {
text-transform: none;
}
-[role="button"] {
- cursor: pointer;
-}
button:not(:disabled),
-[type="button"]:not(:disabled),
[type="submit"]:not(:disabled) {
cursor: pointer;
}
button::-moz-focus-inner,
-[type="button"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
@@ -216,6 +213,10 @@ hr {
.form-group {
margin-bottom: 1rem;
}
+.form-text {
+ display: block;
+ margin-top: 0.25rem;
+}
.btn {
display: inline-block;
font-weight: 400;
@@ -248,8 +249,7 @@ fieldset:disabled a.btn {
.btn-block + .btn-block {
margin-top: 0.5rem;
}
-input.btn-block[type="submit"],
-input.btn-block[type="button"] {
+input.btn-block[type="submit"] {
width: 100%;
}
.custom-control {
@@ -382,10 +382,9 @@ input.btn-block[type="button"] {
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
- font-family: var(--default-regular-font, -apple-system), BlinkMacSystemFont,
- "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue",
- sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
- "Noto Color Emoji";
+ font-family: "GitLab Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1rem;
padding-top: 0.5rem;
@@ -584,9 +583,7 @@ body {
font-size: 0.875rem;
}
button,
-html [type="button"],
-[type="submit"],
-[role="button"] {
+[type="submit"] {
cursor: pointer;
}
h1,
@@ -670,6 +667,11 @@ body.navless {
--top-bar-height: 0px;
--system-footer-height: 0px;
--mr-review-bar-height: 0px;
+ --breakpoint-xs: 0;
+ --breakpoint-sm: 576px;
+ --breakpoint-md: 768px;
+ --breakpoint-lg: 992px;
+ --breakpoint-xl: 1200px;
}
.tab-content {
overflow: visible;
@@ -722,9 +724,6 @@ label {
label.custom-control-label {
font-weight: 400;
}
-label.label-bold {
- font-weight: 600;
-}
.form-control {
border-radius: 4px;
padding: 6px 10px;
@@ -775,18 +774,12 @@ svg {
.gl-display-flex {
display: flex;
}
-.gl-display-inline-block {
- display: inline-block;
-}
.gl-align-items-center {
align-items: center;
}
.gl-justify-content-space-between {
justify-content: space-between;
}
-.gl-float-right {
- float: right;
-}
.gl-w-10 {
width: 3.5rem;
}
@@ -801,16 +794,13 @@ svg {
width: 100%;
}
}
+.gl-p-5 {
+ padding: 1rem;
+}
.gl-px-5 {
padding-left: 1rem;
padding-right: 1rem;
}
-.gl-pt-5 {
- padding-top: 1rem;
-}
-.gl-pb-5 {
- padding-bottom: 1rem;
-}
.gl-py-5 {
padding-top: 1rem;
padding-bottom: 1rem;
@@ -824,9 +814,6 @@ svg {
.gl-mr-auto {
margin-right: auto;
}
-.gl-mb-1 {
- margin-bottom: 0.125rem;
-}
.gl-mb-2 {
margin-bottom: 0.25rem;
}
@@ -844,6 +831,9 @@ svg {
.gl-text-center {
text-align: center;
}
+.gl-text-right {
+ text-align: right;
+}
.gl-font-size-h2 {
font-size: 1.1875rem;
}
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 3a18f735217..e004ca4bb4a 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -261,7 +261,7 @@ body.gl-dark {
}
}
- .header-search {
+ .header-search-form {
background-color: var(--gray-100) !important;
box-shadow: inset 0 0 0 1px var(--border-color) !important;
@@ -296,7 +296,8 @@ body.gl-dark {
}
.timeline-entry.internal-note:not(.note-form) .timeline-content,
-.timeline-entry.draft-note:not(.note-form) .timeline-content {
+.timeline-entry.draft-note:not(.note-form) .timeline-content,
+.discussion-reply-holder.internal-note {
// soften on darkmode
background-color: mix($gray-50, $orange-50, 75%) !important;
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 6e46100dbb3..f841a9047cc 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -68,7 +68,7 @@
> li {
color: $search-and-nav-links;
- &.header-search-new {
+ &.header-search {
color: $gray-900;
}
@@ -151,7 +151,7 @@
}
}
- .header-search {
+ .header-search-form {
background-color: $search-and-nav-links-a20 !important;
border-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss
index a0cbec9a92b..9b7fc10e769 100644
--- a/app/assets/stylesheets/themes/theme_light_gray.scss
+++ b/app/assets/stylesheets/themes/theme_light_gray.scss
@@ -52,7 +52,7 @@ body {
}
}
- .header-search {
+ .header-search-form {
background-color: $white !important;
box-shadow: inset 0 0 0 1px $border-color !important;
border-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index fd378dc7008..08c4efce542 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -153,21 +153,3 @@
.gl-fill-red-500 {
fill: $red-500;
}
-
-/**
- Note: used by app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
- Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab/-/issues/408643
-
- Although this solution uses vendor-prefixes, it is supported by all browsers and it is
- currently the only way to truncate text by lines. See https://caniuse.com/css-line-clamp
-**/
-.gl-truncate-text-by-line {
- // stylelint-disable-next-line value-no-vendor-prefix
- display: -webkit-box;
- -webkit-line-clamp: var(--lines);
- -webkit-box-orient: vertical;
-
- @include gl-media-breakpoint-down(sm) {
- -webkit-line-clamp: var(--mobile-lines);
- }
-}
diff --git a/app/components/diffs/base_component.rb b/app/components/diffs/base_component.rb
index f5bc59cb314..9e1347d1e84 100644
--- a/app/components/diffs/base_component.rb
+++ b/app/components/diffs/base_component.rb
@@ -2,8 +2,6 @@
module Diffs
class BaseComponent < ViewComponent::Base
- warn_on_deprecated_slot_setter
-
# To make converting the partials to components easier,
# we delegate all missing methods to the helpers,
# where they probably are.
diff --git a/app/components/layouts/horizontal_section_component.rb b/app/components/layouts/horizontal_section_component.rb
index caeaa1782c0..48c960f17d9 100644
--- a/app/components/layouts/horizontal_section_component.rb
+++ b/app/components/layouts/horizontal_section_component.rb
@@ -2,8 +2,6 @@
module Layouts
class HorizontalSectionComponent < ViewComponent::Base
- warn_on_deprecated_slot_setter
-
# @param [Boolean] border
# @param [Hash] options
def initialize(border: true, options: {})
diff --git a/app/components/pajamas/alert_component.html.haml b/app/components/pajamas/alert_component.html.haml
index 13c458f05e9..a7be57311bb 100644
--- a/app/components/pajamas/alert_component.html.haml
+++ b/app/components/pajamas/alert_component.html.haml
@@ -2,10 +2,10 @@
- if @show_icon
= sprite_icon(icon, css_class: icon_classes)
- if @dismissible
- %button.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon.js-close{ @close_button_options,
- type: 'button',
- aria: { label: _('Dismiss') } }
- = sprite_icon('close')
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ icon: 'close',
+ size: :small,
+ button_options: dismissible_button_options)
.gl-alert-content{ role: 'alert' }
- if @title
%h4.gl-alert-title
diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb
index 4475f4cde6e..008d624b7e2 100644
--- a/app/components/pajamas/alert_component.rb
+++ b/app/components/pajamas/alert_component.rb
@@ -50,5 +50,13 @@ module Pajamas
def icon_classes
"gl-alert-icon#{' gl-alert-icon-no-title' if @title.nil?}"
end
+
+ def dismissible_button_options
+ new_options = @close_button_options.deep_symbolize_keys # in case strings were used
+ new_options[:class] = "js-close gl-dismiss-btn #{new_options[:class]}"
+ new_options[:aria] ||= {}
+ new_options[:aria][:label] = _('Dismiss') # this will wipe out label if already present
+ new_options
+ end
end
end
diff --git a/app/components/pajamas/component.rb b/app/components/pajamas/component.rb
index a7b45ffd7fd..3b1826a646c 100644
--- a/app/components/pajamas/component.rb
+++ b/app/components/pajamas/component.rb
@@ -2,8 +2,6 @@
module Pajamas
class Component < ViewComponent::Base
- warn_on_deprecated_slot_setter
-
private
# Filter a given a value against a list of allowed values
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 5a4f80fcb32..4cd76311b27 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -57,8 +57,8 @@ class AbuseReportsController < ApplicationController
if @user.nil?
redirect_to root_path, alert: _("Cannot create the abuse report. The user has been deleted.")
- elsif @user.blocked?
- redirect_to @user, alert: _("Cannot create the abuse report. This user has been blocked.")
+ elsif @user.banned?
+ redirect_to @user, alert: _("Cannot create the abuse report. This user has been banned.")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb
index 84e5cc430ef..6b998c3d494 100644
--- a/app/controllers/admin/abuse_reports_controller.rb
+++ b/app/controllers/admin/abuse_reports_controller.rb
@@ -13,7 +13,12 @@ class Admin::AbuseReportsController < Admin::ApplicationController
def show; end
def update
- Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute
+ response = Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute
+ if response.success?
+ render json: { message: response.message }
+ else
+ render json: { message: response.message }, status: :unprocessable_entity
+ end
end
def destroy
diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb
index a5211961d81..d1b87e67800 100644
--- a/app/controllers/admin/background_migrations_controller.rb
+++ b/app/controllers/admin/background_migrations_controller.rb
@@ -18,7 +18,7 @@ module Admin
@current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued'
@migrations = @relations_by_tab[@current_tab].page(params[:page])
@successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id))
- @databases = Gitlab::Database.db_config_names
+ @databases = Gitlab::Database.db_config_names(with_schema: :gitlab_shared)
end
def show
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 821c3cc1635..7f85103816e 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -93,6 +93,7 @@ module Admin
target_path
broadcast_type
dismissable
+ show_in_cli
], target_access_levels: []).reverse_merge!(target_access_levels: [])
end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 0f9ecc60648..001f5242138 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -5,7 +5,7 @@ class Admin::GroupsController < Admin::ApplicationController
before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update]
- feature_category :subgroups, [:create, :destroy, :edit, :index, :members_update, :new, :show, :update]
+ feature_category :groups_and_projects, [:create, :destroy, :edit, :index, :members_update, :new, :show, :update]
def index
@groups = groups.sort_by_attribute(@sort = params[:sort])
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index 57ef75f12e9..c6c0e7eac90 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -3,7 +3,6 @@
class Admin::HooksController < Admin::ApplicationController
include ::WebHooks::HookActions
- feature_category :integrations
urgency :low, [:test]
def test
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 84eb90ce334..e79a899cee7 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -6,7 +6,7 @@ class Admin::ProjectsController < Admin::ApplicationController
before_action :project, only: [:show, :transfer, :repository_check, :destroy, :edit, :update]
before_action :group, only: [:show, :transfer]
- feature_category :projects, [:index, :show, :transfer, :destroy, :edit, :update]
+ feature_category :groups_and_projects, [:index, :show, :transfer, :destroy, :edit, :update]
feature_category :source_code_management, [:repository_check]
def index
diff --git a/app/controllers/admin/topics/avatars_controller.rb b/app/controllers/admin/topics/avatars_controller.rb
index 7acdec424b4..ee0a7e68bb3 100644
--- a/app/controllers/admin/topics/avatars_controller.rb
+++ b/app/controllers/admin/topics/avatars_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::Topics::AvatarsController < Admin::ApplicationController
- feature_category :projects
+ feature_category :groups_and_projects
def destroy
@topic = Projects::Topic.find(params[:topic_id])
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index 94d084932ad..c4de600dd1d 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -6,7 +6,11 @@ class Admin::TopicsController < Admin::ApplicationController
before_action :topic, only: [:edit, :update, :destroy]
- feature_category :projects
+ feature_category :groups_and_projects
+
+ before_action do
+ push_frontend_feature_flag(:content_editor_on_issues, current_user)
+ end
def index
@topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count
@@ -23,7 +27,7 @@ class Admin::TopicsController < Admin::ApplicationController
@topic = Projects::Topic.new(topic_params)
if @topic.save
- redirect_to edit_admin_topic_path(@topic), notice: format(_('Topic %{topic_name} was successfully created.'), topic_name: @topic.name)
+ redirect_to admin_topics_path, notice: format(_('Topic %{topic_name} was successfully created.'), topic_name: @topic.name)
else
render "new"
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 45a7901b2c4..3c96e49499f 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -56,7 +56,7 @@ class Admin::UsersController < Admin::ApplicationController
log_impersonation_event
- flash[:alert] = format(_("You are now impersonating %{username}"), username: user.username)
+ flash[:notice] = format(_("You are now impersonating %{username}"), username: user.username)
redirect_to root_path
else
@@ -87,12 +87,14 @@ class Admin::UsersController < Admin::ApplicationController
end
def activate
- if user.blocked?
- return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated"))
- end
+ activate_service = Users::ActivateService.new(current_user)
+ result = activate_service.execute(user)
- user.activate
- redirect_back_or_admin_user(notice: _("Successfully activated"))
+ if result.success?
+ redirect_back_or_admin_user(notice: _("Successfully activated"))
+ else
+ redirect_back_or_admin_user(alert: result.message)
+ end
end
def deactivate
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9749af08dca..08e4f4956df 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -110,7 +110,7 @@ class ApplicationController < ActionController::Base
rescue_from Gitlab::Git::ResourceExhaustedError do |e|
response.headers.merge!(e.headers)
- render plain: e.message, status: :too_many_requests
+ render plain: e.message, status: :service_unavailable
end
content_security_policy do |p|
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 01cc1ef21c6..c9cb1ca14e2 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -7,7 +7,7 @@ class AutocompleteController < ApplicationController
before_action :check_search_rate_limit!, only: [:users, :projects]
feature_category :user_profile, [:users, :user]
- feature_category :projects, [:projects]
+ feature_category :groups_and_projects, [:projects]
feature_category :team_planning, [:award_emojis]
feature_category :code_review_workflow, [:merge_request_target_branches]
feature_category :continuous_delivery, [:deploy_keys_with_owners]
diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb
index dd5be596ad1..e7b76b87ad9 100644
--- a/app/controllers/clusters/base_controller.rb
+++ b/app/controllers/clusters/base_controller.rb
@@ -10,7 +10,7 @@ class Clusters::BaseController < ApplicationController
feature_category :deployment_management
urgency :low, [
- :index, :show, :environments, :cluster_status, :prometheus_proxy,
+ :index, :show, :environments, :cluster_status,
:destroy, :new_cluster_docs, :connect, :new, :create_user
]
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 873aa5e18dc..2f6331a6822 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -2,7 +2,6 @@
class Clusters::ClustersController < Clusters::BaseController
include RoutableActions
- include Metrics::Dashboard::PrometheusApiProxy
include MetricsDashboard
before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache]
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index 53bb11090c8..896004045f4 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -23,6 +23,8 @@ module CreatesCommit
commit_params = @commit_params.merge(
start_project: start_project,
start_branch: @start_branch,
+ source_project: @project,
+ target_project: target_project,
branch_name: @branch_name
)
diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb
index e562cf5dbe4..aac55af0bac 100644
--- a/app/controllers/concerns/impersonation.rb
+++ b/app/controllers/concerns/impersonation.rb
@@ -6,7 +6,7 @@ module Impersonation
SESSION_KEYS_TO_DELETE = %w[
github_access_token gitea_access_token gitlab_access_token
bitbucket_token bitbucket_refresh_token bitbucket_server_personal_access_token
- bulk_import_gitlab_access_token fogbugz_token
+ bulk_import_gitlab_access_token fogbugz_token cloud_platform_access_token
].freeze
def current_user
diff --git a/app/controllers/concerns/integrations/actions.rb b/app/controllers/concerns/integrations/actions.rb
index c0816c2fe9c..10e86bcc98d 100644
--- a/app/controllers/concerns/integrations/actions.rb
+++ b/app/controllers/concerns/integrations/actions.rb
@@ -7,7 +7,12 @@ module Integrations::Actions
include Integrations::Params
include IntegrationsHelper
+ # :overrides is defined in Admin:IntegrationsController
+ # rubocop:disable Rails/LexicallyScopedActionFilter
+ before_action :ensure_integration_enabled, only: [:edit, :update, :overrides, :test]
before_action :integration, only: [:edit, :update, :overrides, :test]
+ # rubocop:enable Rails/LexicallyScopedActionFilter
+
before_action :render_404, only: :edit, if: -> do
integration.to_param == 'prometheus' && Feature.enabled?(:remove_monitor_metrics)
end
@@ -58,6 +63,10 @@ module Integrations::Actions
@integration ||= find_or_initialize_non_project_specific_integration(params[:id])
end
+ def ensure_integration_enabled
+ render_404 unless integration
+ end
+
def success_message
if integration.active?
format(s_('Integrations|%{integration} settings saved and active.'), integration: integration.title)
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index af984776828..19e458307a1 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -9,6 +9,7 @@ module Integrations
:app_store_key_id,
:app_store_private_key,
:app_store_private_key_file_name,
+ :app_store_protected_refs,
:active,
:alert_events,
:api_key,
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 09b82e36b1a..31675a58163 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -156,7 +156,7 @@ module MembershipActions
[:inherited]
else
if Feature.enabled?(:webui_members_inherited_users, current_user)
- [:inherited, :direct, :shared_from_groups]
+ [:inherited, :direct, :shared_from_groups, (:invited_groups if params[:project_id])].compact
else
[:inherited, :direct]
end
diff --git a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb b/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
deleted file mode 100644
index ea9fd2de961..00000000000
--- a/app/controllers/concerns/metrics/dashboard/prometheus_api_proxy.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-module Metrics::Dashboard::PrometheusApiProxy
- extend ActiveSupport::Concern
- include RenderServiceResults
-
- included do
- before_action :authorize_read_prometheus!, only: [:prometheus_proxy]
- end
-
- def prometheus_proxy
- variable_substitution_result =
- proxy_variable_substitution_service.new(proxyable, permit_params).execute
-
- return error_response(variable_substitution_result) if variable_substitution_result[:status] == :error
-
- prometheus_result = ::Prometheus::ProxyService.new(
- proxyable,
- proxy_method,
- proxy_path,
- variable_substitution_result[:params]
- ).execute
-
- return continue_polling_response if prometheus_result.nil?
- return error_response(prometheus_result) if prometheus_result[:status] == :error
-
- success_response(prometheus_result)
- end
-
- private
-
- def proxyable
- raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
- end
-
- def proxy_variable_substitution_service
- raise NotImplementedError, "#{self.class} must implement method: #{__callee__}"
- end
-
- def permit_params
- params.permit!
- end
-
- def proxy_method
- request.method
- end
-
- def proxy_path
- params[:proxy_path]
- end
-end
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index 7e202235cfa..7a84c597424 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -10,6 +10,8 @@ module MetricsDashboard
extend ActiveSupport::Concern
def metrics_dashboard
+ return not_found if Feature.enabled?(:remove_monitor_metrics)
+
result = dashboard_finder.find(
project_for_dashboard,
current_user,
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 06b9c901e4a..7b2cf131fce 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -62,7 +62,7 @@ module NotesActions
end
if @note.errors.present? && @note.errors.attribute_names != [:commands_only, :command_names]
- render json: json, status: :unprocessable_entity
+ render json: { errors: errors_on_create(@note.errors) }, status: :unprocessable_entity
else
render json: json
end
@@ -75,15 +75,21 @@ module NotesActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def update
@note = Notes::UpdateService.new(project, current_user, update_note_params).execute(note)
- unless @note
+ if @note.destroyed?
head :gone
return
end
- prepare_notes_for_rendering([@note])
-
respond_to do |format|
- format.json { render json: note_json(@note) }
+ format.json do
+ if @note.errors.present?
+ render json: { errors: @note.errors.full_messages.to_sentence }, status: :unprocessable_entity
+ else
+ prepare_notes_for_rendering([@note])
+ render json: note_json(@note)
+ end
+ end
+
format.html { redirect_back_or_default }
end
end
@@ -309,6 +315,12 @@ module NotesActions
noteable.discussions_rendered_on_frontend?
end
+
+ def errors_on_create(errors)
+ return { commands_only: errors.messages[:commands_only] } if errors.key?(:commands_only)
+
+ errors.full_messages.to_sentence
+ end
end
NotesActions.prepend_mod_with('NotesActions')
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index 889d3f0a9d2..d768dae03a2 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -2,7 +2,7 @@
module RendersNotes
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- def prepare_notes_for_rendering(notes, noteable = nil)
+ def prepare_notes_for_rendering(notes)
preload_noteable_for_regular_notes(notes)
preload_max_access_for_authors(notes, @project)
preload_author_status(notes)
diff --git a/app/controllers/concerns/search_rate_limitable.rb b/app/controllers/concerns/search_rate_limitable.rb
index 7cce30dbb3c..1105e9bbbfd 100644
--- a/app/controllers/concerns/search_rate_limitable.rb
+++ b/app/controllers/concerns/search_rate_limitable.rb
@@ -20,9 +20,7 @@ module SearchRateLimitable
def safe_search_scope
# Sometimes search scope can have abusive length or invalid keyword. We don't want
# to send those to redis for rate limit checks, so we guard against that here.
- return if Feature.disabled?(:search_rate_limited_scopes) || abuse_detected?
-
- params[:scope]
+ params[:scope] unless abuse_detected?
end
def abuse_detected?
diff --git a/app/controllers/concerns/skips_already_signed_in_message.rb b/app/controllers/concerns/skips_already_signed_in_message.rb
new file mode 100644
index 00000000000..7630cf4f4e1
--- /dev/null
+++ b/app/controllers/concerns/skips_already_signed_in_message.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# This concern can be included in devise controllers to skip showing an "already signed in"
+# warning on registrations and logins
+module SkipsAlreadySignedInMessage
+ extend ActiveSupport::Concern
+
+ included do
+ # replaced with :require_no_authentication_without_flash
+ # rubocop: disable Rails/LexicallyScopedActionFilter
+ # The actions are defined in Devise
+ skip_before_action :require_no_authentication, only: [:new, :create]
+ before_action :require_no_authentication_without_flash, only: [:new, :create]
+ # rubocop: enable Rails/LexicallyScopedActionFilter
+ end
+
+ def require_no_authentication_without_flash
+ require_no_authentication
+
+ return unless flash[:alert] == I18n.t('devise.failure.already_authenticated')
+
+ flash[:alert] = nil
+ end
+end
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 62c5aee16e4..b14ef8dffa9 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -56,7 +56,7 @@ module SnippetsActions
@noteable = @snippet
@discussions = @snippet.discussions
- @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
render 'show'
end
diff --git a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
index 23db6a4b368..9cad61ed362 100644
--- a/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
+++ b/app/controllers/concerns/spammable_actions/captcha_check/html_format_actions_support.rb
@@ -28,8 +28,13 @@ module SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
# recaptcha gem. This is a field which is automatically included by calling the
# `#recaptcha_tags` method within a HAML template's form.
def convert_html_spam_params_to_headers
+ return unless params['g-recaptcha-response'] || params[:spam_log_id]
+
request.headers['X-GitLab-Captcha-Response'] = params['g-recaptcha-response'] if params['g-recaptcha-response']
request.headers['X-GitLab-Spam-Log-Id'] = params[:spam_log_id] if params[:spam_log_id]
+
+ # Reset the spam_params on the request context, since they have changed mid-request
+ Gitlab::RequestContext.instance.spam_params = ::Spam::SpamParams.new_from_request(request: request)
end
end
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 0d64a685065..222fcc17222 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -11,7 +11,7 @@ module UploadsActions
prepend_before_action :set_request_format_from_path_extension
rescue_from FileUploader::InvalidSecret, with: :render_404
- rescue_from ::Gitlab::Utils::PathTraversalAttackError do
+ rescue_from ::Gitlab::PathTraversal::PathTraversalAttackError do
head :bad_request
end
end
@@ -37,7 +37,7 @@ module UploadsActions
# - or redirect to its URL
#
def show
- Gitlab::Utils.check_path_traversal!(params[:filename])
+ Gitlab::PathTraversal.check_path_traversal!(params[:filename])
return render_404 unless uploader&.exists?
@@ -129,6 +129,14 @@ module UploadsActions
return unless uploader = build_uploader
uploader.retrieve_from_store!(params[:filename])
+
+ Gitlab::AppJsonLogger.info(
+ message: 'Deprecated usage of build_uploader_from_params',
+ uploader_class: uploader.class.name,
+ path: params[:filename],
+ exists: uploader.exists?
+ )
+
uploader
end
diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb
index ae971b7bc95..076347922c8 100644
--- a/app/controllers/concerns/web_hooks/hook_actions.rb
+++ b/app/controllers/concerns/web_hooks/hook_actions.rb
@@ -9,6 +9,7 @@ module WebHooks
attr_writer :hooks, :hook
before_action :hook_logs, only: :edit
+ feature_category :webhooks
end
def index
diff --git a/app/controllers/concerns/web_hooks/hook_log_actions.rb b/app/controllers/concerns/web_hooks/hook_log_actions.rb
index f3378d7c857..321cee5a452 100644
--- a/app/controllers/concerns/web_hooks/hook_log_actions.rb
+++ b/app/controllers/concerns/web_hooks/hook_log_actions.rb
@@ -11,7 +11,7 @@ module WebHooks
respond_to :html
- feature_category :integrations
+ feature_category :webhooks
urgency :low, [:retry]
end
diff --git a/app/controllers/concerns/web_ide_csp.rb b/app/controllers/concerns/web_ide_csp.rb
index c2d66abb538..0327020a0c2 100644
--- a/app/controllers/concerns/web_ide_csp.rb
+++ b/app/controllers/concerns/web_ide_csp.rb
@@ -5,25 +5,27 @@ module WebIdeCSP
included do
before_action :include_web_ide_csp
+ end
- # We want to include frames from `/assets/webpack` of the request's host to
- # support URL flexibility with the Web IDE.
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118875
- def include_web_ide_csp
- return if request.content_security_policy.directives.blank?
+ # We want to include frames from `/assets/webpack` of the request's host to
+ # support URL flexibility with the Web IDE.
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118875
+ def include_web_ide_csp
+ return if request.content_security_policy.directives.blank?
- base_uri = URI(request.url)
- base_uri.path = ::Gitlab.config.gitlab.relative_url_root || '/'
- # `.path +=` handles combining `x/` and `/foo`
- base_uri.path += '/assets/webpack/'
- webpack_url = base_uri.to_s
+ base_uri = URI(request.url)
+ base_uri.path = ::Gitlab.config.gitlab.relative_url_root || '/'
+ # `.path +=` handles combining `x/` and `/foo`
+ base_uri.path += '/assets/webpack/'
+ webpack_url = base_uri.to_s
- default_src = Array(request.content_security_policy.directives['default-src'] || [])
- request.content_security_policy.directives['frame-src'] ||= default_src
- request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.vscode-cdn.net/'])
+ default_src = Array(request.content_security_policy.directives['default-src'] || [])
+ request.content_security_policy.directives['frame-src'] ||= default_src
+ request.content_security_policy.directives['frame-src'].concat([webpack_url, 'https://*.vscode-cdn.net/'])
- request.content_security_policy.directives['worker-src'] ||= default_src
- request.content_security_policy.directives['worker-src'].concat([webpack_url])
- end
+ request.content_security_policy.directives['worker-src'] ||= default_src
+ request.content_security_policy.directives['worker-src'].concat([webpack_url])
end
end
+
+WebIdeCSP.prepend_mod_with('WebIdeCSP')
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 265cf2a7698..c606ccf4a07 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -13,9 +13,10 @@ module WikiActions
included do
content_security_policy do |p|
next if p.directives.blank?
+ next unless Gitlab::CurrentSettings.diagramsnet_enabled?
default_frame_src = p.directives['frame-src'] || p.directives['default-src']
- frame_src_values = Array.wrap(default_frame_src) | ['https://embed.diagrams.net'].compact
+ frame_src_values = Array.wrap(default_frame_src) | [Gitlab::CurrentSettings.diagramsnet_url].compact
p.frame_src(*frame_src_values)
end
diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb
index 552d74686d6..39bee37ee05 100644
--- a/app/controllers/dashboard/groups_controller.rb
+++ b/app/controllers/dashboard/groups_controller.rb
@@ -5,7 +5,7 @@ class Dashboard::GroupsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low, [:index]
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index e26ac083622..eee172995cf 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -14,7 +14,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
before_action :projects, only: [:index]
skip_cross_project_access_check :index, :starred
- feature_category :projects
+ feature_category :groups_and_projects
urgency :low, [:starred, :index]
def index
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index d70b2e57a95..188a8540a58 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -12,6 +12,10 @@ class DashboardController < Dashboard::ApplicationController
before_action :set_show_full_reference, only: [:issues, :merge_requests]
before_action :check_filters_presence!, only: [:issues, :merge_requests]
+ before_action only: :issues do
+ push_frontend_feature_flag(:frontend_caching)
+ end
+
before_action only: :merge_requests do
push_frontend_feature_flag(:mr_approved_filter, type: :ops)
end
diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb
index 96a7b5b144d..6cb0736d7ef 100644
--- a/app/controllers/explore/groups_controller.rb
+++ b/app/controllers/explore/groups_controller.rb
@@ -3,7 +3,7 @@
class Explore::GroupsController < Explore::ApplicationController
include GroupTree
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low
def index
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index eebcbe88ebf..577bd04d656 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -23,7 +23,7 @@ class Explore::ProjectsController < Explore::ApplicationController
rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
- feature_category :projects
+ feature_category :groups_and_projects
# TODO: Set higher urgency after addressing https://gitlab.com/gitlab-org/gitlab/-/issues/357913
# and https://gitlab.com/gitlab-org/gitlab/-/issues/358945
urgency :low, [:index, :topics, :trending, :starred, :topic]
@@ -113,7 +113,9 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def load_topic
- @topic = Projects::Topic.find_by_name_case_insensitive(params[:topic_name])
+ topic_name = Feature.enabled?(:explore_topics_cleaned_path) ? URI.decode_www_form_component(params[:topic_name]) : params[:topic_name]
+
+ @topic = Projects::Topic.find_by_name_case_insensitive(topic_name)
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index ff4fce9ad1e..3d3b7f31dfd 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -12,6 +12,9 @@ class GraphqlController < ApplicationController
# Max size of the query text in characters
MAX_QUERY_SIZE = 10_000
+ # The query string of a standard IntrospectionQuery, used to compare incoming requests for caching
+ CACHED_INTROSPECTION_QUERY_STRING = CachedIntrospectionQuery.query_string
+
# If a user is using their session to access GraphQL, we need to have session
# storage, since the admin-mode check is session wide.
# We can't enable this for anonymous users because that would cause users using
@@ -32,6 +35,7 @@ class GraphqlController < ApplicationController
before_action :set_user_last_activity
before_action :track_vs_code_usage
before_action :track_jetbrains_usage
+ before_action :track_jetbrains_bundled_usage
before_action :track_gitlab_cli_usage
before_action :disable_query_limiting
before_action :limit_query_size
@@ -54,7 +58,12 @@ class GraphqlController < ApplicationController
urgency :low, [:execute]
def execute
- result = multiplex? ? execute_multiplex : execute_query
+ result = if Feature.enabled?(:cache_introspection_query) && params[:operationName] == 'IntrospectionQuery'
+ execute_introspection_query
+ else
+ multiplex? ? execute_multiplex : execute_query
+ end
+
render json: result
end
@@ -80,7 +89,7 @@ class GraphqlController < ApplicationController
log_exception(exception)
response.headers.merge!(exception.headers)
- render_error(exception.message, status: :too_many_requests)
+ render_error(exception.message, status: :service_unavailable)
end
rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
@@ -169,6 +178,11 @@ class GraphqlController < ApplicationController
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
end
+ def track_jetbrains_bundled_usage
+ Gitlab::UsageDataCounters::JetBrainsBundledPluginActivityUniqueCounter
+ .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
+ end
+
def track_gitlab_cli_usage
Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter
.track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
@@ -259,4 +273,46 @@ class GraphqlController < ApplicationController
def logs
RequestStore.store[:graphql_logs].to_a
end
+
+ def execute_introspection_query
+ if introspection_query_can_use_cache?
+ Gitlab::AppLogger.info(message: "IntrospectionQueryCache hit")
+ log_introspection_query_cache_details(true)
+
+ # Context for caching: https://gitlab.com/gitlab-org/gitlab/-/issues/409448
+ Rails.cache.fetch(
+ introspection_query_cache_key,
+ expires_in: 1.day) do
+ execute_query.to_json
+ end
+ else
+ Gitlab::AppLogger.info(message: "IntrospectionQueryCache miss")
+ log_introspection_query_cache_details(false)
+
+ execute_query
+ end
+ end
+
+ def introspection_query_can_use_cache?
+ graphql_query = GraphQL::Query.new(GitlabSchema, query: query, variables: build_variables(params[:variables]))
+
+ CACHED_INTROSPECTION_QUERY_STRING == graphql_query.query_string.squish
+ end
+
+ def introspection_query_cache_key
+ # We use context[:remove_deprecated] here as an introspection query result can differ based on the
+ # visibility of schema items. Visibility can be affected by the remove_deprecated param. For more context, see:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/409448#note_1377558096
+ ['introspection-query-cache', Gitlab.revision, context[:remove_deprecated]]
+ end
+
+ def log_introspection_query_cache_details(can_use_introspection_query_cache)
+ Gitlab::AppLogger.info(
+ message: "IntrospectionQueryCache",
+ can_use_introspection_query_cache: can_use_introspection_query_cache.to_s,
+ query: query,
+ variables: build_variables(params[:variables]).to_s,
+ introspection_query_cache_key: introspection_query_cache_key.to_s
+ )
+ end
end
diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb
index 3cad9e1fbad..414461d9e93 100644
--- a/app/controllers/groups/autocomplete_sources_controller.rb
+++ b/app/controllers/groups/autocomplete_sources_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Groups::AutocompleteSourcesController < Groups::ApplicationController
- feature_category :subgroups, [:members]
+ feature_category :groups_and_projects, [:members]
feature_category :team_planning, [:issues, :labels, :milestones, :commands]
feature_category :code_review_workflow, [:merge_requests]
diff --git a/app/controllers/groups/avatars_controller.rb b/app/controllers/groups/avatars_controller.rb
index 1f13be449a9..3b34a1947ae 100644
--- a/app/controllers/groups/avatars_controller.rb
+++ b/app/controllers/groups/avatars_controller.rb
@@ -5,7 +5,7 @@ class Groups::AvatarsController < Groups::ApplicationController
skip_cross_project_access_check :destroy
- feature_category :subgroups
+ feature_category :groups_and_projects
def destroy
@group.remove_avatar!
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index ca3be1542aa..98d7487b21a 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -9,7 +9,7 @@ module Groups
skip_cross_project_access_check :index
- feature_category :subgroups
+ feature_category :groups_and_projects
# TODO: Set to higher urgency after resolving https://gitlab.com/gitlab-org/gitlab/-/issues/331494
urgency :low, [:index]
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index 1b1aed0ec2e..1fc631f299b 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -121,7 +121,7 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
end
def manifest_file_name
- @manifest_file_name ||= Gitlab::Utils.check_path_traversal!("#{image}:#{tag}.json")
+ @manifest_file_name ||= Gitlab::PathTraversal.check_path_traversal!("#{image}:#{tag}.json")
end
def group
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index c74c48a960d..a874c7d164d 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -4,7 +4,7 @@ class Groups::GroupLinksController < Groups::ApplicationController
before_action :authorize_admin_group!
before_action :group_link, only: [:update, :destroy]
- feature_category :subgroups
+ feature_category :groups_and_projects
def update
Groups::GroupLinks::UpdateService.new(@group_link, current_user).execute(group_link_params)
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index d614cc1cb24..de47c5fb5e3 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -24,7 +24,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
skip_cross_project_access_check :index, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite, :override
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low
def index
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 903c8c214ae..5f6b55ea928 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -44,7 +44,19 @@ class Groups::MilestonesController < Groups::ApplicationController
def update
Milestones::UpdateService.new(@milestone.parent, current_user, milestone_params).execute(@milestone)
- redirect_to milestone_path(@milestone)
+ respond_to do |format|
+ format.html do
+ redirect_to milestone_path(@milestone)
+ end
+
+ format.json do
+ if @milestone.valid?
+ head :no_content
+ else
+ render json: { errors: @milestone.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+ end
rescue ActiveRecord::StaleObjectError
respond_to do |format|
format.html do
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index 0a63c3d304b..59b24e8103d 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -12,7 +12,9 @@ module Groups
layout 'group_settings'
def index
- @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_group(group)).sort_by(&:title)
+ @integrations = Integration
+ .find_or_initialize_all_non_project_specific(Integration.for_group(group))
+ .sort_by(&:title)
end
def edit
diff --git a/app/controllers/groups/shared_projects_controller.rb b/app/controllers/groups/shared_projects_controller.rb
index 2d2664c02e8..73f951d6ce4 100644
--- a/app/controllers/groups/shared_projects_controller.rb
+++ b/app/controllers/groups/shared_projects_controller.rb
@@ -6,7 +6,7 @@ module Groups
before_action :group
skip_cross_project_access_check :index
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low, [:index]
def index
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index 22e6549aa04..cd1ebc39411 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -9,7 +9,7 @@ class Groups::UploadsController < Groups::ApplicationController
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low, [:show]
private
diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb
index 125c8fde004..be4e08f6c49 100644
--- a/app/controllers/groups/usage_quotas_controller.rb
+++ b/app/controllers/groups/usage_quotas_controller.rb
@@ -24,7 +24,7 @@ module Groups
render_404 unless group.usage_quotas_enabled?
end
- # To be overriden in ee/app/controllers/ee/groups/usage_quotas_controller.rb
+ # To be overridden in ee/app/controllers/ee/groups/usage_quotas_controller.rb
def seat_count_data; end
end
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index d2f65104d86..ec16be8f85e 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -36,6 +36,7 @@ class GroupsController < Groups::ApplicationController
push_frontend_feature_flag(:or_issuable_queries, group)
push_frontend_feature_flag(:frontend_caching, group)
push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?)
+ push_frontend_feature_flag(:issues_grid_view)
end
before_action only: :merge_requests do
@@ -51,14 +52,12 @@ class GroupsController < Groups::ApplicationController
layout :determine_layout
- feature_category :subgroups, [
+ feature_category :groups_and_projects, [
:index, :new, :create, :show, :edit, :update,
- :destroy, :details, :transfer, :activity
+ :destroy, :details, :transfer, :activity, :projects
]
-
feature_category :team_planning, [:issues, :issues_calendar, :preview_markdown]
feature_category :code_review_workflow, [:merge_requests, :unfoldered_environment_names]
- feature_category :projects, [:projects]
feature_category :importers, [:export, :download_export]
urgency :low, [:export, :download_export]
diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb
index 3c50d54fa10..2c498820a1e 100644
--- a/app/controllers/jira_connect/app_descriptor_controller.rb
+++ b/app/controllers/jira_connect/app_descriptor_controller.rb
@@ -8,7 +8,7 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
skip_before_action :verify_atlassian_jwt!
def show
- render json: {
+ result = {
name: Atlassian::JiraConnect.app_name,
description: 'Integrate commits, branches and merge requests from GitLab into Jira',
key: Atlassian::JiraConnect.app_key,
@@ -36,10 +36,15 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
gdpr: true
}
}
+
+ result[:links][:feedback] = URI.join(HOME_URL, FEEDBACK_URL) if Feature.enabled?(:jira_for_cloud_app_feedback_link)
+
+ render json: result
end
private
+ FEEDBACK_URL = '/gitlab-org/gitlab/-/issues/413652'
HOME_URL = 'https://gitlab.com'
DOC_URL = 'https://docs.gitlab.com/ee/integration/jira/'
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index a2e0670d7e1..eda72400f17 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -15,7 +15,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
feature_category :system_access
def handle_omniauth
- omniauth_flow(Gitlab::Auth::OAuth)
+ if ::AuthHelper.saml_providers.include?(oauth['provider'].to_sym)
+ saml
+ else
+ omniauth_flow(Gitlab::Auth::OAuth)
+ end
end
AuthHelper.providers_for_base_controller.each do |provider|
@@ -30,6 +34,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# Extend the standard implementation to also increment
# the number of failed sign in attempts
def failure
+ update_login_counter_metric(failed_strategy.name, 'failed')
+
if params[:username].present? && AuthHelper.form_based_provider?(failed_strategy.name)
user = User.find_by_login(params[:username])
@@ -79,6 +85,21 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
+ def track_event(user, provider, status)
+ log_audit_event(user, with: provider)
+ update_login_counter_metric(provider, status)
+ end
+
+ def update_login_counter_metric(provider, status)
+ omniauth_login_counter.increment(omniauth_provider: provider, status: status)
+ end
+
+ def omniauth_login_counter
+ @counter ||= Gitlab::Metrics.counter(
+ :gitlab_omniauth_login_total,
+ 'Counter of OmniAuth login attempts')
+ end
+
def log_failed_login(user, provider)
# overridden in EE
end
@@ -99,7 +120,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if current_user
return render_403 unless link_provider_allowed?(oauth['provider'])
- log_audit_event(current_user, with: oauth['provider'])
+ track_event(current_user, oauth['provider'], 'succeeded')
if Gitlab::CurrentSettings.admin_mode
return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
@@ -151,7 +172,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# from that in `#context_user`. Pushing it manually here makes the information
# available in the logs for this request.
Gitlab::ApplicationContext.push(user: user)
- log_audit_event(user, with: oauth['provider'])
+ track_event(user, oauth['provider'], 'succeeded')
Gitlab::Tracking.event(self.class.name, "#{oauth['provider']}_sso", user: user) if new_user
set_remember_me(user)
@@ -167,7 +188,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
accept_pending_invitations(user: user) if new_user
persist_accepted_terms_if_required(user) if new_user
- store_after_sign_up_path_for_user if intent_to_register?
+ perform_registration_tasks(user, oauth['provider']) if new_user
sign_in_and_redirect_or_verify_identity(user, auth_user, new_user)
end
else
@@ -249,11 +270,6 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
(request_params['remember_me'] == '1') if request_params.present?
end
- def intent_to_register?
- request_params = request.env['omniauth.params']
- (request_params['intent'] == 'register') if request_params.present?
- end
-
def store_redirect_fragment(redirect_fragment)
key = stored_location_key_for(:user)
location = session[key]
@@ -295,8 +311,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
Users::RespondToTermsService.new(user, terms).execute(accepted: true)
end
- def store_after_sign_up_path_for_user
- store_location_for(:user, users_sign_up_welcome_path)
+ def perform_registration_tasks(_user, _provider)
+ store_location_for(:user, after_sign_up_path)
+ end
+
+ def after_sign_up_path
+ users_sign_up_welcome_path
end
# overridden in EE
diff --git a/app/controllers/organizations/application_controller.rb b/app/controllers/organizations/application_controller.rb
new file mode 100644
index 00000000000..5f5a57d176b
--- /dev/null
+++ b/app/controllers/organizations/application_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Organizations
+ class ApplicationController < ::ApplicationController
+ before_action :organization
+
+ private
+
+ def organization
+ return unless params[:organization_path]
+
+ @organization = Organizations::Organization.find_by_path(params[:organization_path])
+ end
+ strong_memoize_attr :organization
+
+ def authorize_action!(action)
+ access_denied! if Feature.disabled?(:ui_for_organizations)
+ access_denied! unless can?(current_user, action, organization)
+ end
+ end
+end
diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb
new file mode 100644
index 00000000000..0eb5c3aa6fd
--- /dev/null
+++ b/app/controllers/organizations/organizations_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Organizations
+ class OrganizationsController < ApplicationController
+ feature_category :cell
+
+ before_action { authorize_action!(:admin_organization) }
+
+ def directory; end
+ end
+end
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index a5a2cbf3733..f19113276c2 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -54,6 +54,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:sourcegraph_enabled,
:gitpod_enabled,
:render_whitespace_in_code,
+ :project_shortcut_buttons,
:markdown_surround_selection,
:markdown_automatic_lists,
:use_new_navigation
diff --git a/app/controllers/profiles/slacks_controller.rb b/app/controllers/profiles/slacks_controller.rb
new file mode 100644
index 00000000000..7c78c01416a
--- /dev/null
+++ b/app/controllers/profiles/slacks_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Profiles
+ class SlacksController < Profiles::ApplicationController
+ include IntegrationsHelper
+
+ skip_before_action :authenticate_user!
+
+ layout 'application'
+
+ feature_category :integrations
+
+ def edit
+ @projects = disabled_projects.inc_routes if current_user
+ end
+
+ def slack_link
+ project = disabled_projects.find(params[:project_id])
+ link = add_to_slack_link(project, Gitlab::CurrentSettings.slack_app_id)
+
+ render json: { add_to_slack_link: link }
+ end
+
+ private
+
+ def disabled_projects
+ @disabled_projects ||= current_user
+ .authorized_projects(Gitlab::Access::MAINTAINER)
+ .with_slack_application_disabled
+ end
+ end
+end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index bc6e67a3a7d..e83b72b71a8 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -35,9 +35,9 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
render 'create'
else
@error = { message: _('Invalid pin code.') }
- @qr_code = build_qr_code
@account_string = account_string
- setup_webauthn_registration
+
+ setup_show_page
render 'show'
end
diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb
index 345d7bdbca8..ef3144f6f8c 100644
--- a/app/controllers/profiles/webauthn_registrations_controller.rb
+++ b/app/controllers/profiles/webauthn_registrations_controller.rb
@@ -4,8 +4,7 @@ class Profiles::WebauthnRegistrationsController < Profiles::ApplicationControlle
feature_category :system_access
def destroy
- webauthn_registration = current_user.webauthn_registrations.find(params[:id])
- webauthn_registration.destroy
+ Webauthn::DestroyService.new(current_user, current_user, params[:id]).execute
redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted WebAuthn device.")
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index b5b023a4d64..2828d17c36f 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -19,10 +19,6 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy]
before_action :entry, only: [:external_file, :file]
- before_action only: :index do
- push_frontend_feature_flag(:ci_job_artifact_bulk_destroy, @project)
- end
-
MAX_PER_PAGE = 20
feature_category :build_artifacts
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index ffe6071ab3c..480e3408023 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -6,7 +6,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
feature_category :team_planning, [:issues, :labels, :milestones, :commands, :contacts]
feature_category :code_review_workflow, [:merge_requests]
- feature_category :user_profile, [:members]
+ feature_category :groups_and_projects, [:members]
feature_category :source_code_management, [:snippets]
urgency :low, [:merge_requests, :members]
diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb
index 5db7609e07a..3728406afd3 100644
--- a/app/controllers/projects/avatars_controller.rb
+++ b/app/controllers/projects/avatars_controller.rb
@@ -5,7 +5,7 @@ class Projects::AvatarsController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:destroy]
- feature_category :projects
+ feature_category :groups_and_projects
urgency :low, [:show]
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index bb1b8760c42..f621adbebc7 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -9,6 +9,7 @@ class Projects::BlameController < Projects::ApplicationController
before_action :assign_ref_vars
before_action :authorize_read_code!
before_action :load_blob
+ before_action :require_non_binary_blob
feature_category :source_code_management
urgency :low, [:show]
@@ -40,6 +41,10 @@ class Projects::BlameController < Projects::ApplicationController
redirect_to_tree_root_for_missing_path(@project, @ref, @path)
end
+ def require_non_binary_blob
+ redirect_to project_blob_path(@project, File.join(@ref, @path)), notice: _('Blame for binary files is not supported.') if @blob.binary?
+ end
+
def load_environment
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
environment_params[:find_latest] = true
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 727a4e0251d..28393e1f365 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -49,7 +49,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 1e17dd586c7..e60544129ff 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -27,6 +27,7 @@ class Projects::BranchesController < Projects::ApplicationController
# Fetch branches for the specified mode
fetch_branches_by_mode
+ fetch_merge_requests_for_branches
@refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name))
@merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
@@ -199,6 +200,15 @@ class Projects::BranchesController < Projects::ApplicationController
Projects::BranchesByModeService.new(@project, params.merge(sort: @sort, mode: @mode)).execute
end
+ def fetch_merge_requests_for_branches
+ @related_merge_requests = @project
+ .source_of_merge_requests
+ .including_target_project
+ .by_target_branch(@project.default_branch)
+ .by_sorted_source_branches(@branches.map(&:name))
+ .group_by(&:source_branch)
+ end
+
def fetch_branches_for_overview
# Here we get one more branch to indicate if there are more data we're not showing
limit = @overview_max_branches + 1
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index d874c60daec..8499bf0ced7 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -4,7 +4,8 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:ci_job_assistant_drawer, @project)
- push_frontend_feature_flag(:ai_ci_config_generator, @project)
+ push_frontend_feature_flag(:ai_ci_config_generator, @user)
+ push_frontend_feature_flag(:ci_graphql_pipeline_mini_graph, @project)
end
feature_category :pipeline_composition
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 8aca6a3fd5b..88e9113188a 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -19,6 +19,9 @@ class Projects::CommitController < Projects::ApplicationController
before_action :define_commit_box_vars, only: [:show, :pipelines]
before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
+ before_action do
+ push_frontend_feature_flag(:ci_graphql_pipeline_mini_graph, @project)
+ end
BRANCH_SEARCH_LIMIT = 1000
COMMIT_DIFFS_PER_PAGE = 20
@@ -219,7 +222,7 @@ class Projects::CommitController < Projects::ApplicationController
end
@notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes)
- @notes = prepare_notes_for_rendering(@notes, @commit)
+ @notes = prepare_notes_for_rendering(@notes)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 10dd18c0c86..9d7569047f6 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -26,7 +26,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
if project.licensed_feature_available?(:cycle_analytics_for_projects)
push_licensed_feature(:cycle_analytics_for_projects)
- push_frontend_feature_flag(:vsa_group_and_project_parity, @project)
end
end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index a61930d4b99..59de4fbb698 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -34,7 +34,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
def render_discussion
if serialize_notes?
- prepare_notes_for_rendering(discussion.notes, merge_request)
+ prepare_notes_for_rendering(discussion.notes)
render_json_with_discussions_serializer
else
render_json_with_html
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
deleted file mode 100644
index cbb16d596a0..00000000000
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::Environments::PrometheusApiController < Projects::ApplicationController
- include Metrics::Dashboard::PrometheusApiProxy
-
- before_action :proxyable
-
- feature_category :metrics
- urgency :low
-
- private
-
- def proxyable
- @proxyable ||= project.environments.find(params[:id])
- end
-
- def proxy_variable_substitution_service
- ::Prometheus::ProxyVariableSubstitutionService
- end
-end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index f91ec55573d..10d0d03e56d 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -1,22 +1,13 @@
# frozen_string_literal: true
class Projects::EnvironmentsController < Projects::ApplicationController
- # Metrics dashboard code is getting decoupled from environments and is being moved
- # into app/controllers/projects/metrics_dashboard_controller.rb
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details.
-
MIN_SEARCH_LENGTH = 3
- include MetricsDashboard
include ProductAnalyticsTracking
include KasCookie
layout 'project'
- before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
- authorize_metrics_dashboard!
- end
-
before_action only: [:show] do
push_frontend_feature_flag(:environment_details_vue, @project)
end
@@ -25,15 +16,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:kas_user_access_project, @project)
end
- before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
+ before_action only: [:edit, :new] do
+ push_frontend_feature_flag(:environment_settings_to_graphql, @project)
+ end
+
+ before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
- before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop]
+ before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
- before_action :set_kas_cookie, only: [:index], if: -> { current_user }
+ before_action :set_kas_cookie, only: [:index], if: -> { current_user && request.format.html? }
after_action :expire_etag_cache, only: [:cancel_auto_stop]
track_event :index, :folder, :show, :new, :edit, :create, :update, :stop, :cancel_auto_stop, :terminal,
@@ -175,41 +170,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
- def metrics_redirect
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- redirect_to project_metrics_dashboard_path(project)
- end
-
- def metrics
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- respond_to do |format|
- format.html do
- redirect_to project_metrics_dashboard_path(project, environment: environment)
- end
- format.json do
- # Currently, this acts as a hint to load the metrics details into the cache
- # if they aren't there already
- @metrics = environment.metrics || {}
-
- render json: @metrics, status: @metrics.any? ? :ok : :no_content
- end
- end
- end
-
- def additional_metrics
- return not_found if Feature.enabled?(:remove_monitor_metrics)
-
- respond_to do |format|
- format.json do
- additional_metrics = environment.additional_metrics(*metrics_params) || {}
-
- render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
- end
- end
- end
-
def search
respond_to do |format|
format.json do
@@ -261,16 +221,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@search_environments ||= Environments::EnvironmentsFinder.new(project, current_user, type: type, search: search).execute
end
- def metrics_params
- params.require([:start, :end])
- end
-
- def metrics_dashboard_params
- params
- .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment, :sample_metrics, :embed_json)
- .merge(dashboard_path: params[:dashboard], environment: environment)
- end
-
def include_all_dashboards?
!params[:embedded]
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 451f1d1363b..60300f78bbb 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -6,7 +6,7 @@ class Projects::GroupLinksController < Projects::ApplicationController
before_action :authorize_admin_project_group_link!, only: [:destroy]
before_action :authorize_admin_project_member!, only: [:update]
- feature_category :subgroups
+ feature_category :groups_and_projects
def update
Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params)
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index 570fe74f31f..412ed529446 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -12,7 +12,6 @@ class Projects::HooksController < Projects::ApplicationController
layout "project_settings"
- feature_category :integrations
urgency :low, [:test]
def test
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 642d5943854..6311907a859 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -50,6 +50,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project)
push_frontend_feature_flag(:saved_replies, current_user)
+ push_frontend_feature_flag(:issues_grid_view)
end
before_action only: [:index, :show] do
@@ -157,8 +158,7 @@ class Projects::IssuesController < Projects::ApplicationController
discussion_to_resolve: params[:discussion_to_resolve]
)
- spam_params = ::Spam::SpamParams.new_from_request(request: request)
- service = ::Issues::CreateService.new(container: project, current_user: current_user, params: create_params, spam_params: spam_params)
+ service = ::Issues::CreateService.new(container: project, current_user: current_user, params: create_params)
result = service.execute
# Only irrecoverable errors such as unauthorized user won't contain an issue in the response
@@ -372,8 +372,11 @@ class Projects::IssuesController < Projects::ApplicationController
end
def update_service
- spam_params = ::Spam::SpamParams.new_from_request(request: request)
- ::Issues::UpdateService.new(container: project, current_user: current_user, params: issue_params, spam_params: spam_params)
+ ::Issues::UpdateService.new(
+ container: project,
+ current_user: current_user,
+ params: issue_params,
+ perform_spam_check: true)
end
def finder_type
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 3a03831ab88..06381315614 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -91,15 +91,17 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
end
def target_projects
- projects = MergeRequestTargetProjectFinder
- .new(current_user: current_user, source_project: @project, project_feature: :repository)
- .execute(include_routes: false, search: params[:search]).limit(20)
-
- render json: ProjectSerializer.new.represent(projects)
+ render json: ProjectSerializer.new.represent(get_target_projects)
end
private
+ def get_target_projects
+ MergeRequestTargetProjectFinder
+ .new(current_user: current_user, source_project: @project, project_feature: :repository)
+ .execute(include_routes: false, search: params[:search]).limit(20)
+ end
+
def build_merge_request
params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 6ca885cee4c..f3a01fd3223 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -13,7 +13,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
around_action :allow_gitaly_ref_name_caching
- after_action :track_viewed_diffs_events, only: [:diffs_batch]
+ after_action :track_viewed_diffs_events, only: [:diffs_batch, :diff_for_path]
urgency :low, [
:show,
@@ -196,7 +196,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
- @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
+ @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
end
def render_merge_ref_head_diff?
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index ad3b79b604c..60f619a8d20 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -41,19 +41,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_force_frontend_feature_flag(:content_editor_on_issues, project&.content_editor_on_issues_feature_flag_enabled?)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
- push_frontend_feature_flag(:refactor_security_extension, @project)
push_frontend_feature_flag(:deprecate_vulnerabilities_feedback, @project)
push_frontend_feature_flag(:moved_mr_sidebar, project)
- push_frontend_feature_flag(:single_file_file_by_file, project)
push_frontend_feature_flag(:mr_experience_survey, project)
- push_frontend_feature_flag(:realtime_mr_status_change, project)
- push_frontend_feature_flag(:realtime_approvals, project)
push_frontend_feature_flag(:saved_replies, current_user)
push_frontend_feature_flag(:code_quality_inline_drawer, project)
- push_frontend_feature_flag(:hide_create_issue_resolve_all, project)
push_frontend_feature_flag(:auto_merge_labels_mr_widget, project)
push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?)
push_frontend_feature_flag(:mr_activity_filters, current_user)
+ push_frontend_feature_flag(:review_apps_redeploy_mr_widget, project)
+ push_frontend_feature_flag(:comment_on_files, current_user)
+ push_frontend_feature_flag(:ci_job_failures_in_mr, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :diffs, :discussions]
@@ -196,10 +194,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
end
+ # documented in doc/development/rails_endpoints/index.md
def codequality_mr_diff_reports
reports_response(@merge_request.find_codequality_mr_diff_reports, head_pipeline)
end
+ # documented in doc/development/rails_endpoints/index.md
def codequality_reports
reports_response(@merge_request.compare_codequality_reports)
end
@@ -613,8 +613,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
Feature.enabled?(:summarize_my_code_review, current_user) &&
namespace.group_namespace? &&
namespace.licensed_feature_available?(:summarize_my_mr_code_review) &&
- Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review) &&
- merge_request.send_to_ai?
+ Gitlab::Llm::StageCheck.available?(namespace, :summarize_my_mr_code_review)
end
end
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
deleted file mode 100644
index 510c882d537..00000000000
--- a/app/controllers/projects/metrics_dashboard_controller.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-module Projects
- class MetricsDashboardController < Projects::ApplicationController
- # Metrics dashboard code is in the process of being decoupled from environments
- # and is getting moved to this controller. Some code may be duplicated from
- # app/controllers/projects/environments_controller.rb
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/226002 for more details.
-
- include Gitlab::Utils::StrongMemoize
-
- before_action :authorize_metrics_dashboard!
- before_action :render_404, only: :show, if: -> do
- Feature.enabled?(:remove_monitor_metrics)
- end
-
- feature_category :metrics
- urgency :low
-
- def show
- if environment
- render 'projects/environments/metrics'
- elsif default_environment
- redirect_to project_metrics_dashboard_path(
- project,
- # Reverse merge the query parameters so that a query parameter named dashboard_path doesn't
- # override the dashboard_path path parameter.
- **permitted_params.to_h.symbolize_keys
- .merge(environment: default_environment.id)
- .reverse_merge(request.query_parameters.symbolize_keys)
- )
- else
- render 'projects/environments/empty_metrics'
- end
- end
-
- private
-
- def permitted_params
- @permitted_params ||= params.permit(:dashboard_path, :environment, :page)
- end
-
- def environment
- strong_memoize(:environment) do
- env = permitted_params[:environment]
- project.environments.find(env) if env
- end
- end
-
- def default_environment
- strong_memoize(:default_environment) do
- project.default_environment
- end
- end
- end
-end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 569a514b23b..35b65dbce7e 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -76,7 +76,6 @@ class Projects::MilestonesController < Projects::ApplicationController
@milestone = Milestones::UpdateService.new(project, current_user, milestone_params).execute(milestone)
respond_to do |format|
- format.js
format.html do
if @milestone.valid?
redirect_to project_milestone_path(@project, @milestone)
@@ -84,6 +83,16 @@ class Projects::MilestonesController < Projects::ApplicationController
render :edit
end
end
+
+ format.js
+
+ format.json do
+ if @milestone.valid?
+ head :no_content
+ else
+ render json: { errors: @milestone.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
end
rescue ActiveRecord::StaleObjectError
respond_to do |format|
diff --git a/app/controllers/projects/ml/candidates_controller.rb b/app/controllers/projects/ml/candidates_controller.rb
index e534000f494..ed7155fc5f4 100644
--- a/app/controllers/projects/ml/candidates_controller.rb
+++ b/app/controllers/projects/ml/candidates_controller.rb
@@ -3,7 +3,7 @@
module Projects
module Ml
class CandidatesController < ApplicationController
- before_action :check_feature_flag, :set_candidate
+ before_action :check_feature_enabled, :set_candidate
feature_category :mlops
@@ -26,8 +26,8 @@ module Projects
render_404 unless @candidate.present?
end
- def check_feature_flag
- render_404 unless Feature.enabled?(:ml_experiment_tracking, @project)
+ def check_feature_enabled
+ render_404 unless can?(current_user, :read_model_experiments, @project)
end
end
end
diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb
index dece3f98c57..a620e9919e7 100644
--- a/app/controllers/projects/ml/experiments_controller.rb
+++ b/app/controllers/projects/ml/experiments_controller.rb
@@ -5,7 +5,7 @@ module Projects
class ExperimentsController < ::Projects::ApplicationController
include Projects::Ml::ExperimentsHelper
- before_action :check_feature_flag
+ before_action :check_feature_enabled
before_action :set_experiment, only: [:show, :destroy]
feature_category :mlops
@@ -55,8 +55,8 @@ module Projects
private
- def check_feature_flag
- render_404 unless Feature.enabled?(:ml_experiment_tracking, @project)
+ def check_feature_enabled
+ render_404 unless can?(current_user, :read_model_experiments, @project)
end
def set_experiment
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 5cb69e8bf99..8682d35aae7 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -12,6 +12,9 @@ class Projects::PagesDomainsController < Projects::ApplicationController
feature_category :pages
def show
+ return unless domain_presenter.needs_verification?
+
+ flash.now[:warning] = _("This domain is not verified. You will need to verify ownership before access is enabled.")
end
def new
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 39ebcd60e9a..98e6459b543 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -22,6 +22,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy]
+ before_action :push_frontend_feature_flags, only: [:show, :builds, :dag, :failures, :test_report]
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
@@ -190,7 +191,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def cancel
- pipeline.cancel_running
+ ::Ci::CancelPipelineService.new(pipeline: pipeline, current_user: @current_user).execute
respond_to do |format|
format.html do
@@ -349,6 +350,10 @@ class Projects::PipelinesController < Projects::ApplicationController
def tracking_project_source
project
end
+
+ def push_frontend_feature_flags
+ push_frontend_feature_flag(:pipeline_details_header_vue, @project)
+ end
end
Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController')
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index f4b96177b0f..5390df449e8 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -8,7 +8,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
- feature_category :projects
+ feature_category :groups_and_projects
urgency :low
def index
diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb
index 27ac64e5758..80a8dbf4729 100644
--- a/app/controllers/projects/prometheus/alerts_controller.rb
+++ b/app/controllers/projects/prometheus/alerts_controller.rb
@@ -3,8 +3,6 @@
module Projects
module Prometheus
class AlertsController < Projects::ApplicationController
- include MetricsDashboard
-
respond_to :json
protect_from_forgery except: [:notify]
@@ -14,7 +12,6 @@ module Projects
prepend_before_action :repository, :project_without_auth, only: [:notify]
before_action :authorize_read_prometheus_alerts!, except: [:notify]
- before_action :alert, only: [:metrics_dashboard]
feature_category :incident_management
urgency :low
@@ -33,17 +30,6 @@ module Projects
.new(project, params.permit!)
end
- def alert
- @alert ||= alerts_finder(metric: params[:id]).execute.first || render_404
- end
-
- def alerts_finder(opts = {})
- Projects::Prometheus::AlertsFinder.new({
- project: project,
- environment: params[:environment_id]
- }.reverse_merge(opts))
- end
-
def extract_alert_manager_token(request)
Doorkeeper::OAuth::Token.from_bearer_authorization(request)
end
@@ -52,13 +38,6 @@ module Projects
@project ||= Project
.find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}")
end
-
- def metrics_dashboard_params
- {
- embedded: true,
- prometheus_alert_id: alert.id
- }
- end
end
end
end
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
index c20c80ba334..396841e667d 100644
--- a/app/controllers/projects/prometheus/metrics_controller.rb
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -3,6 +3,7 @@
module Projects
module Prometheus
class MetricsController < Projects::ApplicationController
+ before_action :check_feature_availability!
before_action :authorize_admin_project!
before_action :require_prometheus_metrics!
@@ -127,6 +128,10 @@ module Projects
def metrics_params
params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group)
end
+
+ def check_feature_availability!
+ render_404 if Feature.enabled?(:remove_monitor_metrics)
+ end
end
end
end
diff --git a/app/controllers/projects/redirect_controller.rb b/app/controllers/projects/redirect_controller.rb
index 6bcbe87ee42..c66a99be02b 100644
--- a/app/controllers/projects/redirect_controller.rb
+++ b/app/controllers/projects/redirect_controller.rb
@@ -6,7 +6,7 @@
class Projects::RedirectController < ::ApplicationController
skip_before_action :authenticate_user!
- feature_category :projects
+ feature_category :groups_and_projects
def redirect_from_id
project = Project.find(params[:id])
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 7c569df7267..6a6a47bc33d 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -74,6 +74,6 @@ class Projects::ReleasesController < Projects::ApplicationController
end
def validate_suffix_path
- Gitlab::Utils.check_path_traversal!(params[:suffix_path]) if params[:suffix_path]
+ Gitlab::PathTraversal.check_path_traversal!(params[:suffix_path]) if params[:suffix_path]
end
end
diff --git a/app/controllers/projects/settings/branch_rules_controller.rb b/app/controllers/projects/settings/branch_rules_controller.rb
index 0a415b60124..68ef7f49cc3 100644
--- a/app/controllers/projects/settings/branch_rules_controller.rb
+++ b/app/controllers/projects/settings/branch_rules_controller.rb
@@ -7,9 +7,7 @@ module Projects
feature_category :source_code_management
- def index
- render_404 unless Feature.enabled?(:branch_rules, project)
- end
+ def index; end
end
end
end
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 952c9e90a2c..5f30edd3db8 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -122,22 +122,14 @@ module Projects
{
incident_management_setting_attributes: ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys,
- metrics_setting_attributes: [:external_dashboard_url, :dashboard_timezone],
-
error_tracking_setting_attributes: [
:enabled,
:integrated,
:api_host,
:token,
project: [:slug, :name, :organization_slug, :organization_name, :sentry_project_id]
- ],
-
- grafana_integration_attributes: [:token, :grafana_url, :enabled]
- }.tap do |potential_params|
- if Feature.enabled?(:remove_monitor_metrics)
- potential_params.except!(:metrics_setting_attributes, :grafana_integration_attributes)
- end
- end
+ ]
+ }
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index a6f4e2fcd73..38b23b24c9a 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -12,7 +12,6 @@ module Projects
urgency :low, [:show, :create_deploy_token]
def show
- push_frontend_feature_flag(:branch_rules, @project)
render_show
end
diff --git a/app/controllers/projects/settings/slacks_controller.rb b/app/controllers/projects/settings/slacks_controller.rb
new file mode 100644
index 00000000000..4e55103cb4c
--- /dev/null
+++ b/app/controllers/projects/settings/slacks_controller.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Projects
+ module Settings
+ class SlacksController < Projects::ApplicationController
+ before_action :handle_oauth_error, only: :slack_auth
+ before_action :check_oauth_state, only: :slack_auth
+ before_action :authorize_admin_project!
+ before_action :slack_integration, only: [:edit, :update]
+ before_action :service, only: [:destroy, :edit, :update]
+
+ layout 'project_settings'
+
+ feature_category :integrations
+
+ def slack_auth
+ result = Projects::SlackApplicationInstallService.new(project, current_user, params).execute
+
+ flash[:alert] = result[:message] if result[:status] == :error
+
+ session[:slack_install_success] = true
+ redirect_to_service_page
+ end
+
+ def destroy
+ slack_integration.destroy
+
+ redirect_to_service_page
+ end
+
+ def edit; end
+
+ def update
+ if slack_integration.update(slack_integration_params)
+ flash[:notice] = 'The project alias was updated successfully'
+
+ redirect_to_service_page
+ else
+ render :edit
+ end
+ end
+
+ private
+
+ def redirect_to_service_page
+ redirect_to edit_project_settings_integration_path(
+ project,
+ project.gitlab_slack_application_integration || project.build_gitlab_slack_application_integration
+ )
+ end
+
+ def check_oauth_state
+ render_403 unless valid_authenticity_token?(session, params[:state])
+
+ true
+ end
+
+ def handle_oauth_error
+ return unless params[:error] == 'access_denied'
+
+ flash[:alert] = 'Access denied'
+ redirect_to_service_page
+ end
+
+ def slack_integration
+ @slack_integration ||= project.gitlab_slack_application_integration.slack_integration
+ end
+
+ def service
+ @service = project.gitlab_slack_application_integration
+ end
+
+ def slack_integration_params
+ params.require(:slack_integration).permit(:alias)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb
index 06996e8e5fc..f9f71ce72d3 100644
--- a/app/controllers/projects/starrers_controller.rb
+++ b/app/controllers/projects/starrers_controller.rb
@@ -3,7 +3,7 @@
class Projects::StarrersController < Projects::ApplicationController
include SortingHelper
- feature_category :projects
+ feature_category :groups_and_projects
urgency :low, [:index]
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 495241df912..c8f698d6193 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,7 +18,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:synchronize_fork, @project.fork_source)
push_frontend_feature_flag(:explain_code_chat, current_user)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 0f3143606ff..81f205a6457 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -38,9 +38,9 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
push_frontend_feature_flag(:remove_monitor_metrics, @project)
push_frontend_feature_flag(:explain_code_chat, current_user)
+ push_frontend_feature_flag(:ci_namespace_catalog_experimental, @project)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
@@ -50,7 +50,7 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
- feature_category :projects, [
+ feature_category :groups_and_projects, [
:index, :show, :new, :create, :edit, :update, :transfer,
:destroy, :archive, :unarchive, :toggle_star, :activity
]
@@ -263,12 +263,12 @@ class ProjectsController < Projects::ApplicationController
@project.add_export_job(current_user: current_user)
redirect_to(
- edit_project_path(@project, anchor: 'js-export-project'),
+ edit_project_path(@project, anchor: 'js-project-advanced-settings'),
notice: _("Project export started. A download link will be sent by email and made available on this page.")
)
rescue Project::ExportLimitExceeded => e
redirect_to(
- edit_project_path(@project, anchor: 'js-export-project'),
+ edit_project_path(@project, anchor: 'js-project-advanced-settings'),
alert: e.to_s
)
end
@@ -279,13 +279,13 @@ class ProjectsController < Projects::ApplicationController
send_upload(@project.export_file, attachment: @project.export_file.filename)
else
redirect_to(
- edit_project_path(@project, anchor: 'js-export-project'),
+ edit_project_path(@project, anchor: 'js-project-advanced-settings'),
alert: _("The file containing the export is not available yet; it may still be transferring. Please try again later.")
)
end
else
redirect_to(
- edit_project_path(@project, anchor: 'js-export-project'),
+ edit_project_path(@project, anchor: 'js-project-advanced-settings'),
alert: _("Project export link has expired. Please generate a new export from your project settings.")
)
end
@@ -298,7 +298,7 @@ class ProjectsController < Projects::ApplicationController
flash[:alert] = _("Project export could not be deleted.")
end
- redirect_to(edit_project_path(@project, anchor: 'js-export-project'))
+ redirect_to(edit_project_path(@project, anchor: 'js-project-advanced-settings'))
end
def generate_new_export
@@ -306,7 +306,7 @@ class ProjectsController < Projects::ApplicationController
export
else
redirect_to(
- edit_project_path(@project, anchor: 'js-export-project'),
+ edit_project_path(@project, anchor: 'js-project-advanced-settings'),
alert: _("Project export could not be deleted.")
)
end
@@ -456,6 +456,7 @@ class ProjectsController < Projects::ApplicationController
feature_flags_access_level
monitor_access_level
infrastructure_access_level
+ model_experiments_access_level
]
end
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index ac8959e0f52..76aa4afbe80 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -4,6 +4,7 @@ module Registrations
class WelcomeController < ApplicationController
include OneTrustCSP
include GoogleAnalyticsCSP
+ include ::Gitlab::Utils::StrongMemoize
layout 'minimal'
skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update]
@@ -24,6 +25,7 @@ module Registrations
if result.success?
track_event('successfully_submitted_form')
+ finish_onboarding_on_welcome_page unless complete_signup_onboarding?
redirect_to update_success_path
else
@@ -34,6 +36,8 @@ module Registrations
private
def registering_from_invite?(members)
+ # If there are more than one member it will mean we have been invited to multiple projects/groups and
+ # are not able to distinguish which one we should putting the user in after registration
members.count == 1 && members.last.source.present?
end
@@ -61,30 +65,37 @@ module Registrations
end
# overridden in EE
- def redirect_to_signup_onboarding?
+ def complete_signup_onboarding?
false
end
- def redirect_for_tasks_to_be_done?
- MemberTask.for_members(current_user.members).exists?
+ def invites_with_tasks_to_be_done?
+ MemberTask.for_members(user_members).exists?
end
def update_success_path
- return issues_dashboard_path(assignee_username: current_user.username) if redirect_for_tasks_to_be_done?
-
- return signup_onboarding_path if redirect_to_signup_onboarding?
-
- members = current_user.members
-
- if registering_from_invite?(members)
- flash[:notice] = helpers.invite_accepted_notice(members.last)
- members_activity_path(members)
+ if invites_with_tasks_to_be_done?
+ issues_dashboard_path(assignee_username: current_user.username)
+ elsif complete_signup_onboarding? # trials/regular registration on .com
+ signup_onboarding_path
+ elsif registering_from_invite?(user_members) # invites w/o tasks due to order
+ flash[:notice] = helpers.invite_accepted_notice(user_members.last)
+ members_activity_path(user_members)
else
- # subscription registrations goes through here as well
+ # Subscription registrations goes through here as well.
+ # Invites will come here too if there is more than 1.
path_for_signed_in_user(current_user)
end
end
+ def user_members
+ current_user.members
+ end
+ strong_memoize_attr :user_members
+
+ # overridden in EE
+ def finish_onboarding_on_welcome_page; end
+
# overridden in EE
def signup_onboarding_path; end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 3e6683fc867..f481681da02 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -10,6 +10,7 @@ class RegistrationsController < Devise::RegistrationsController
include GoogleAnalyticsCSP
include PreferredLanguageSwitcher
include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent
+ include SkipsAlreadySignedInMessage
layout 'devise'
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index a3c6499bc54..45aefe48538 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -34,6 +34,7 @@ class SearchController < ApplicationController
before_action only: :show do
update_scope_for_code_search
end
+
rescue_from ActiveRecord::QueryCanceled, with: :render_timeout
layout 'search'
@@ -111,11 +112,12 @@ class SearchController < ApplicationController
@project = search_service.project
@ref = params[:project_ref] if params[:project_ref].present?
@filter = params[:filter]
+ @scope = params[:scope]
# Cache the response on the frontend
expires_in 1.minute
- render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter))
+ render json: Gitlab::Json.dump(search_autocomplete_opts(term, filter: @filter, scope: @scope))
end
def opensearch
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 6069924b39a..6c5e709a98a 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -29,6 +29,10 @@ class SentNotificationsController < ApplicationController
def unsubscribe_and_redirect
noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project)
+ if noteable.is_a?(Issue) && @sent_notification.recipient_id == User.support_bot.id
+ noteable.unsubscribe_email_participant(noteable.external_author)
+ end
+
flash[:notice] = _("You have been unsubscribed from this thread.")
if current_user
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 8a79353f490..a9972cbd885 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -14,13 +14,11 @@ class SessionsController < Devise::SessionsController
include VerifiesWithEmail
include GoogleAnalyticsCSP
include PreferredLanguageSwitcher
+ include SkipsAlreadySignedInMessage
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
- # replaced with :require_no_authentication_without_flash
- skip_before_action :require_no_authentication, only: [:new, :create]
-
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
if: -> { action_name == 'create' && two_factor_enabled? }
@@ -29,7 +27,6 @@ class SessionsController < Devise::SessionsController
prepend_before_action :require_no_authentication_without_flash, only: [:new, :create]
prepend_before_action :check_forbidden_password_based_login, if: -> { action_name == 'create' && password_based_login? }
prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? }
-
before_action :auto_sign_in_with_provider, only: [:new]
before_action :init_preferred_language, only: :new
before_action :store_unauthenticated_sessions, only: [:new]
@@ -96,14 +93,6 @@ class SessionsController < Devise::SessionsController
private
- def require_no_authentication_without_flash
- require_no_authentication
-
- if flash[:alert] == I18n.t('devise.failure.already_authenticated')
- flash[:alert] = nil
- end
- end
-
def captcha_enabled?
request.headers[CAPTCHA_HEADER] && helpers.recaptcha_enabled?
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 1a966739401..b797a204d7f 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -29,7 +29,7 @@ class UploadsController < ApplicationController
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
- feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned
+ feature_category :team_planning
def self.model_classes
MODEL_CLASSES
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
index e1dddcb2c66..4f9c00980e1 100644
--- a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
+++ b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
@@ -31,7 +31,7 @@ git push -uf origin <%= @project.default_branch_or_main %>
- [ ] [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") %>)
- [ ] [Enable merge request approvals](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/approvals/") %>)
-- [ ] [Automatically merge when pipeline succeeds](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>)
+- [ ] [Set auto-merge](<%= redirect("https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html") %>)
## Test and Deploy
diff --git a/app/finders/alert_management/http_integrations_finder.rb b/app/finders/alert_management/http_integrations_finder.rb
index e8e85da11b7..77a3824576f 100644
--- a/app/finders/alert_management/http_integrations_finder.rb
+++ b/app/finders/alert_management/http_integrations_finder.rb
@@ -2,7 +2,9 @@
module AlertManagement
class HttpIntegrationsFinder
- def initialize(project, params)
+ TYPE_IDENTIFIERS = ::AlertManagement::HttpIntegration.type_identifiers
+
+ def initialize(project, params = {})
@project = project
@params = params
end
@@ -13,6 +15,7 @@ module AlertManagement
filter_by_availability
filter_by_endpoint_identifier
filter_by_active
+ filter_by_type
collection
end
@@ -21,15 +24,13 @@ module AlertManagement
attr_reader :project, :params, :collection
+ # Overridden in EE
def filter_by_availability
- return if multiple_alert_http_integrations?
-
- first_id = project.alert_management_http_integrations
- .ordered_by_id
- .select(:id)
- .limit(1)
-
- @collection = collection.id_in(first_id)
+ # Re-find by id so subsequent filters don't expose unavailable records
+ @collection = collection.id_in(collection
+ .select('DISTINCT ON (type_identifier) id')
+ .ordered_by_type_and_id
+ .limit(TYPE_IDENTIFIERS.length))
end
def filter_by_endpoint_identifier
@@ -44,9 +45,11 @@ module AlertManagement
@collection = collection.active
end
- # Overridden in EE
- def multiple_alert_http_integrations?
- false
+ def filter_by_type
+ return unless params[:type_identifier]
+ return unless TYPE_IDENTIFIERS.include?(params[:type_identifier])
+
+ @collection = collection.for_type(params[:type_identifier])
end
end
end
diff --git a/app/finders/crm/organizations_finder.rb b/app/finders/crm/organizations_finder.rb
index 69f72235c71..8701d26dd6e 100644
--- a/app/finders/crm/organizations_finder.rb
+++ b/app/finders/crm/organizations_finder.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Finder for retrieving organizations scoped to a group
+# Finder for retrieving crm_organizations scoped to a group
#
# Arguments:
# current_user - user performing the action. Must have the correct permission level for the group.
@@ -29,22 +29,22 @@ module Crm
def execute
return CustomerRelations::Organization.none unless root_group
- organizations = root_group.organizations
- organizations = by_ids(organizations)
- organizations = by_search(organizations)
- organizations = by_state(organizations)
- sort_organizations(organizations)
+ crm_organizations = root_group.crm_organizations
+ crm_organizations = by_ids(crm_organizations)
+ crm_organizations = by_search(crm_organizations)
+ crm_organizations = by_state(crm_organizations)
+ sort_crm_organizations(crm_organizations)
end
private
- def sort_organizations(organizations)
- return organizations.sort_by_name unless @params.key?(:sort)
- return organizations if @params[:sort].nil?
+ def sort_crm_organizations(crm_organizations)
+ return crm_organizations.sort_by_name unless @params.key?(:sort)
+ return crm_organizations if @params[:sort].nil?
field = @params[:sort][:field]
direction = @params[:sort][:direction]
- organizations.sort_by_field(field, direction)
+ crm_organizations.sort_by_field(field, direction)
end
def root_group
@@ -57,22 +57,22 @@ module Crm
end
end
- def by_search(organizations)
- return organizations unless search?
+ def by_search(crm_organizations)
+ return crm_organizations unless search?
- organizations.search(params[:search])
+ crm_organizations.search(params[:search])
end
- def by_state(organizations)
- return organizations unless state?
+ def by_state(crm_organizations)
+ return crm_organizations unless state?
- organizations.search_by_state(params[:state])
+ crm_organizations.search_by_state(params[:state])
end
- def by_ids(organizations)
- return organizations unless ids?
+ def by_ids(crm_organizations)
+ return crm_organizations unless ids?
- organizations.id_in(params[:ids])
+ crm_organizations.id_in(params[:ids])
end
def search?
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index 316dffcb3b2..5241a3b3907 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -57,22 +57,8 @@ class DeploymentsFinder
raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified'
end
- # Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API.
- # We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500.
if filter_by_updated_at? && !order_by_updated_at?
- error = InefficientQueryError.new('`updated_at` filter requires `updated_at` sort')
-
- Gitlab::ErrorTracking.log_exception(error)
-
- # We are adding a Feature Flag to introduce the breaking change indicated in
- # https://gitlab.com/gitlab-org/gitlab/-/issues/328500
- # We are also adding a way to override this flag for special case users that
- # are running into large volume of errors when the flag is enabled.
- # These Feature Flags must be removed by 16.1
- if Feature.enabled?(:deployments_raise_updated_at_inefficient_error) &&
- Feature.disabled?(:deployments_raise_updated_at_inefficient_error_override, params[:project])
- raise error
- end
+ raise InefficientQueryError, '`updated_at` filter requires `updated_at` sort'
end
if filter_by_finished_at? && !order_by_finished_at?
diff --git a/app/finders/groups/environment_scopes_finder.rb b/app/finders/groups/environment_scopes_finder.rb
new file mode 100644
index 00000000000..886be7881ee
--- /dev/null
+++ b/app/finders/groups/environment_scopes_finder.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Groups::EnvironmentsScopesFinder
+#
+# Arguments:
+# group
+# params:
+# search: string
+#
+module Groups
+ class EnvironmentScopesFinder
+ DEFAULT_ENVIRONMENT_SCOPES_LIMIT = 100
+
+ def initialize(group:, params: {})
+ @group = group
+ @params = params
+ end
+
+ EnvironmentScope = Struct.new(:name)
+
+ def execute
+ variables = group.variables
+ variables = by_name(variables)
+ variables = by_search(variables)
+ variables = variables.limit(DEFAULT_ENVIRONMENT_SCOPES_LIMIT)
+ environment_scope_names = variables.environment_scope_names
+ environment_scope_names.map { |environment_scope| EnvironmentScope.new(environment_scope) }
+ end
+
+ private
+
+ attr_reader :group, :params
+
+ def by_name(group_variables)
+ if params[:name].present?
+ group_variables.by_environment_scope(params[:name])
+ else
+ group_variables
+ end
+ end
+
+ def by_search(group_variables)
+ if params[:search].present?
+ group_variables.for_environment_scope_like(params[:search])
+ else
+ group_variables
+ end
+ end
+ end
+end
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 24003111f88..63f7616884f 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -72,17 +72,10 @@ class GroupsFinder < UnionFinder
# rubocop: disable CodeReuse/ActiveRecord
def groups_with_min_access_level
- groups = current_user
+ current_user
.groups
.where('members.access_level >= ?', params[:min_access_level])
-
- if Feature.enabled?(:use_traversal_ids_groups_finder, current_user)
- groups.self_and_descendants
- else
- Gitlab::ObjectHierarchy
- .new(groups)
- .base_and_descendants
- end
+ .self_and_descendants
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -110,13 +103,11 @@ class GroupsFinder < UnionFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def by_search(groups)
return groups unless params[:search].present?
groups.search(params[:search], include_parents: params[:parent].blank?)
end
- # rubocop: enable CodeReuse/ActiveRecord
def owned_groups
current_user&.owned_groups || Group.none
@@ -145,20 +136,13 @@ class GroupsFinder < UnionFinder
def get_groups_for_user
groups = []
- if Feature.enabled?(:use_traversal_ids_groups_finder, current_user)
- groups << if include_ancestors?
- current_user.authorized_groups.self_and_ancestors
- else
- current_user.authorized_groups
- end
+ groups << if include_ancestors?
+ current_user.authorized_groups.self_and_ancestors
+ else
+ current_user.authorized_groups
+ end
- groups << current_user.groups.self_and_descendants
- elsif include_ancestors?
- groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects
- else
- groups << current_user.authorized_groups
- groups << Gitlab::ObjectHierarchy.new(groups_for_descendants).base_and_descendants
- end
+ groups << current_user.groups.self_and_descendants
groups
end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index f1c5d5e08ad..f7ee90ab870 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -158,19 +158,15 @@ class MergeRequestsFinder < IssuableFinder
return items if draft_param.nil?
if draft_param
- items.where(wip_match(items.arel_table))
+ items.where(draft_match(items.arel_table))
else
- items.where.not(wip_match(items.arel_table))
+ items.where.not(draft_match(items.arel_table))
end
end
# rubocop: enable CodeReuse/ActiveRecord
- # WIP is deprecated in favor of Draft. Currently both options are supported
- def wip_match(table)
- table[:title].matches('WIP:%')
- .or(table[:title].matches('WIP %'))
- .or(table[:title].matches('[WIP]%'))
- .or(table[:title].matches('Draft - %'))
+ def draft_match(table)
+ table[:title].matches('Draft - %')
.or(table[:title].matches('Draft:%'))
.or(table[:title].matches('[Draft]%'))
.or(table[:title].matches('(Draft)%'))
diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb
index c96f9527dd8..0194ee40801 100644
--- a/app/finders/namespaces/projects_finder.rb
+++ b/app/finders/namespaces/projects_finder.rb
@@ -32,6 +32,8 @@ module Namespaces
namespace.projects.with_route
end
+ collection = collection.not_aimed_for_deletion if params[:not_aimed_for_deletion].present?
+
collection = filter_projects(collection)
sort(collection)
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
index 78240e0a050..c7d35f62673 100644
--- a/app/finders/releases_finder.rb
+++ b/app/finders/releases_finder.rb
@@ -6,18 +6,19 @@ class ReleasesFinder
attr_reader :parent, :current_user, :params
def initialize(parent, current_user = nil, params = {})
- @parent = parent
+ @parent = Array.wrap(parent)
@current_user = current_user
@params = params
params[:order_by] ||= 'released_at'
+ params[:order_by_for_latest] ||= 'released_at'
params[:sort] ||= 'desc'
end
def execute(preload: true)
- return Release.none if projects.empty?
+ return Release.none if authorized_projects.empty?
- releases = get_releases
+ releases = params[:latest] ? get_latest_releases : get_releases
releases = by_tag(releases)
releases = releases.preloaded if preload
order_releases(releases)
@@ -26,17 +27,22 @@ class ReleasesFinder
private
def get_releases
- Release.where(project_id: projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord
+ Release.where(project_id: authorized_projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord
end
- def projects
- strong_memoize(:projects) do
- if parent.is_a?(Project)
- Ability.allowed?(current_user, :read_release, parent) ? [parent] : []
- end
- end
+ def get_latest_releases
+ Release.latest_for_projects(authorized_projects, order_by: params[:order_by_for_latest]).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord
end
+ def authorized_projects
+ # Preload policy for all projects to avoid N+1 queries
+ projects = Project.id_in(parent.map(&:id)).include_project_feature
+ Preloaders::ProjectPolicyPreloader.new(projects, current_user).execute
+
+ projects.select { |project| authorized?(project) }
+ end
+ strong_memoize_attr :authorized_projects
+
# rubocop: disable CodeReuse/ActiveRecord
def by_tag(releases)
return releases unless params[:tag].present?
@@ -48,4 +54,8 @@ class ReleasesFinder
def order_releases(releases)
releases.sort_by_attribute("#{params[:order_by]}_#{params[:sort]}")
end
+
+ def authorized?(project)
+ Ability.allowed?(current_user, :read_release, project)
+ end
end
diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb
index c6c5c30cbf7..a8dd2cde20f 100644
--- a/app/finders/template_finder.rb
+++ b/app/finders/template_finder.rb
@@ -7,7 +7,6 @@ class TemplateFinder
dockerfiles: ::Gitlab::Template::DockerfileTemplate,
gitignores: ::Gitlab::Template::GitignoreTemplate,
gitlab_ci_ymls: ::Gitlab::Template::GitlabCiYmlTemplate,
- metrics_dashboard_ymls: ::Gitlab::Template::MetricsDashboardTemplate,
issues: ::Gitlab::Template::IssueTemplate,
merge_requests: ::Gitlab::Template::MergeRequestTemplate
).freeze
@@ -28,14 +27,9 @@ class TemplateFinder
end
def type_allowed?(type)
- case type.to_s
- when 'licenses'
- true
- when 'metrics_dashboard_ymls'
- !Feature.enabled?(:remove_monitor_metrics)
- else
- VENDORED_TEMPLATES.key?(type)
- end
+ return true if type.to_s == 'licenses'
+
+ VENDORED_TEMPLATES.key?(type)
end
end
diff --git a/app/finders/uploader_finder.rb b/app/finders/uploader_finder.rb
index 0d1de0d56fd..e4a0e831720 100644
--- a/app/finders/uploader_finder.rb
+++ b/app/finders/uploader_finder.rb
@@ -16,12 +16,12 @@ class UploaderFinder
retrieve_file_state!
uploader
- rescue ::Gitlab::Utils::PathTraversalAttackError
+ rescue ::Gitlab::PathTraversal::PathTraversalAttackError
nil # no-op if for incorrect files
end
def prevent_path_traversal_attack!
- Gitlab::Utils.check_path_traversal!(@file_path)
+ Gitlab::PathTraversal.check_path_traversal!(@file_path)
end
def retrieve_file_state!
diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb
index 11e3c341c1f..57dbeca5c51 100644
--- a/app/finders/users_finder.rb
+++ b/app/finders/users_finder.rb
@@ -80,7 +80,15 @@ class UsersFinder
def by_search(users)
return users unless params[:search].present?
- users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?)
+ if Feature.enabled?(:autocomplete_users_use_search_service)
+ users.search(
+ params[:search],
+ with_private_emails: current_user&.can_admin_all_resources?,
+ use_minimum_char_limit: params[:use_minimum_char_limit]
+ )
+ else
+ users.search(params[:search], with_private_emails: current_user&.can_admin_all_resources?)
+ end
end
def by_blocked(users)
diff --git a/app/graphql/cached_introspection_query.rb b/app/graphql/cached_introspection_query.rb
new file mode 100644
index 00000000000..f2b98426714
--- /dev/null
+++ b/app/graphql/cached_introspection_query.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module CachedIntrospectionQuery
+ def self.query_string
+ <<~QUERY.squish
+ query IntrospectionQuery {
+ __schema {
+ queryType {
+ name
+ }
+ mutationType {
+ name
+ }
+ subscriptionType {
+ name
+ }
+ types {
+ ...FullType
+ }
+ directives {
+ name
+ description
+ locations
+ args {
+ ...InputValue
+ }
+ }
+ }
+ }
+
+ fragment FullType on __Type {
+ kind
+ name
+ description
+ fields(includeDeprecated: true) {
+ name
+ description
+ args {
+ ...InputValue
+ }
+ type {
+ ...TypeRef
+ }
+ isDeprecated
+ deprecationReason
+ }
+ inputFields {
+ ...InputValue
+ }
+ interfaces {
+ ...TypeRef
+ }
+ enumValues(includeDeprecated: true) {
+ name
+ description
+ isDeprecated
+ deprecationReason
+ }
+ possibleTypes {
+ ...TypeRef
+ }
+ }
+
+ fragment InputValue on __InputValue {
+ name
+ description
+ type {
+ ...TypeRef
+ }
+ defaultValue
+ }
+
+ fragment TypeRef on __Type {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ ofType {
+ kind
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ QUERY
+ end
+end
diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb
index d1798d2ade7..527eb50b644 100644
--- a/app/graphql/graphql_triggers.rb
+++ b/app/graphql/graphql_triggers.rb
@@ -48,8 +48,6 @@ module GraphqlTriggers
end
def self.merge_request_merge_status_updated(merge_request)
- return unless Feature.enabled?(:realtime_mr_status_change, merge_request.project)
-
GitlabSchema.subscriptions.trigger(
:merge_request_merge_status_updated, { issuable_id: merge_request.to_gid }, merge_request
)
@@ -60,6 +58,14 @@ module GraphqlTriggers
:merge_request_approval_state_updated, { issuable_id: merge_request.to_gid }, merge_request
)
end
+
+ def self.work_item_updated(work_item)
+ # becomes is necessary here since this can be triggered with both a WorkItem and also an Issue
+ # depending on the update service the call comes from
+ work_item = work_item.becomes(::WorkItem) if work_item.is_a?(::Issue) # rubocop:disable Cop/AvoidBecomes
+
+ ::GitlabSchema.subscriptions.trigger('workItemUpdated', { work_item_id: work_item.to_gid }, work_item)
+ end
end
GraphqlTriggers.prepend_mod
diff --git a/app/graphql/mutations/achievements/delete_user_achievement.rb b/app/graphql/mutations/achievements/delete_user_achievement.rb
new file mode 100644
index 00000000000..f1527c2981a
--- /dev/null
+++ b/app/graphql/mutations/achievements/delete_user_achievement.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Achievements
+ class DeleteUserAchievement < BaseMutation
+ graphql_name 'UserAchievementsDelete'
+
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ field :user_achievement,
+ ::Types::Achievements::UserAchievementType,
+ null: true,
+ description: 'Deleted user achievement.'
+
+ argument :user_achievement_id, ::Types::GlobalIDType[::Achievements::UserAchievement],
+ required: true,
+ description: 'Global ID of the user achievement being deleted.'
+
+ authorize :destroy_user_achievement
+
+ def resolve(args)
+ user_achievement = authorized_find!(id: args[:user_achievement_id])
+
+ result = ::Achievements::DestroyUserAchievementService.new(current_user, user_achievement).execute
+ { user_achievement: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Achievements::UserAchievement)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb
deleted file mode 100644
index a7d99d2a496..00000000000
--- a/app/graphql/mutations/ci/ci_cd_settings_update.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module Ci
- # TODO: Remove after 16.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/361801#note_1373963840
- class CiCdSettingsUpdate < ProjectCiCdSettingsUpdate
- graphql_name 'CiCdSettingsUpdate'
-
- def ready?(**args)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`remove_cicd_settings_update` feature flag is enabled.' \
- if Feature.enabled?(:remove_cicd_settings_update)
-
- super
- end
- end
- end
-end
diff --git a/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb
index 53036496de4..1ef59fbeba4 100644
--- a/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb
+++ b/app/graphql/mutations/ci/job_artifact/bulk_destroy.rb
@@ -38,11 +38,6 @@ module Mutations
project = authorized_find!(id: project_id)
- if Feature.disabled?(:ci_job_artifact_bulk_destroy, project)
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- '`ci_job_artifact_bulk_destroy` feature flag is disabled.'
- end
-
raise Gitlab::Graphql::Errors::ArgumentError, 'IDs array of job artifacts can not be empty' if ids.empty?
result = ::Ci::JobArtifacts::BulkDeleteByProjectService.new(
diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb
index c52e3b4f4b8..810f458fd75 100644
--- a/app/graphql/mutations/ci/pipeline/cancel.rb
+++ b/app/graphql/mutations/ci/pipeline/cancel.rb
@@ -11,12 +11,12 @@ module Mutations
def resolve(id:)
pipeline = authorized_find!(id: id)
- if pipeline.cancelable?
- pipeline.cancel_running
+ result = ::Ci::CancelPipelineService.new(pipeline: pipeline, current_user: current_user).execute
+ if result.success?
{ success: true, errors: [] }
else
- { success: false, errors: ['Pipeline is not cancelable'] }
+ { success: false, errors: [result.message] }
end
end
end
diff --git a/app/graphql/mutations/dependency_proxy/group_settings/update.rb b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
index 6be07edd883..ee510373f34 100644
--- a/app/graphql/mutations/dependency_proxy/group_settings/update.rb
+++ b/app/graphql/mutations/dependency_proxy/group_settings/update.rb
@@ -8,10 +8,11 @@ module Mutations
include Mutations::ResolvesGroup
- description 'These settings can be adjusted by the group Owner or Maintainer. However, in GitLab 16.0, we ' \
- 'will be limiting this to the Owner role. ' \
- '[GitLab-#364441](https://gitlab.com/gitlab-org/gitlab/-/issues/364441) proposes making ' \
- 'this change to match the permissions level in the user interface.'
+ description <<~DESC
+ These settings can be adjusted by the group Owner or Maintainer.
+ [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting
+ this to Owners only to match the permissions level in the user interface.
+ DESC
authorize :admin_dependency_proxy
diff --git a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
index 79d7a93c4e2..0759b8e1beb 100644
--- a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
+++ b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
@@ -8,6 +8,12 @@ module Mutations
include Mutations::ResolvesGroup
+ description <<~DESC
+ These settings can be adjusted by the group Owner or Maintainer.
+ [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting
+ this to Owners only to match the permissions level in the user interface.
+ DESC
+
authorize :admin_dependency_proxy
argument :group_path,
diff --git a/app/graphql/mutations/environments/create.rb b/app/graphql/mutations/environments/create.rb
new file mode 100644
index 00000000000..271585eb06c
--- /dev/null
+++ b/app/graphql/mutations/environments/create.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ class Create < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentCreate'
+ description 'Create an environment.'
+
+ include FindsProject
+
+ authorize :create_environment
+
+ argument :project_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Full path of the project.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Name of the environment.'
+
+ argument :external_url,
+ GraphQL::Types::String,
+ required: false,
+ description: 'External URL of the environment.'
+
+ argument :tier,
+ Types::DeploymentTierEnum,
+ required: false,
+ description: 'Tier of the environment.'
+
+ argument :cluster_agent_id,
+ ::Types::GlobalIDType[::Clusters::Agent],
+ required: false,
+ description: 'Cluster agent of the environment.'
+
+ field :environment,
+ Types::EnvironmentType,
+ null: true,
+ description: 'Created environment.'
+
+ def resolve(project_path:, **kwargs)
+ project = authorized_find!(project_path)
+
+ kwargs[:cluster_agent] = GitlabSchema.find_by_gid(kwargs.delete(:cluster_agent_id))&.sync
+
+ response = ::Environments::CreateService.new(project, current_user, kwargs).execute
+
+ if response.success?
+ { environment: response.payload[:environment], errors: [] }
+ else
+ { environment: response.payload[:environment], errors: response.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/environments/delete.rb b/app/graphql/mutations/environments/delete.rb
new file mode 100644
index 00000000000..5e3958b7936
--- /dev/null
+++ b/app/graphql/mutations/environments/delete.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ class Delete < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentDelete'
+ description 'Delete an environment.'
+
+ authorize :destroy_environment
+
+ argument :id,
+ ::Types::GlobalIDType[::Environment],
+ required: true,
+ description: 'Global ID of the environment to Delete.'
+
+ def resolve(id:, **kwargs)
+ environment = authorized_find!(id: id)
+
+ response = ::Environments::DestroyService.new(environment.project, current_user, kwargs).execute(environment)
+
+ if response.success?
+ { errors: [] }
+ else
+ { errors: response.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/environments/update.rb b/app/graphql/mutations/environments/update.rb
new file mode 100644
index 00000000000..431a7add00e
--- /dev/null
+++ b/app/graphql/mutations/environments/update.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ class Update < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentUpdate'
+ description 'Update an environment.'
+
+ authorize :update_environment
+
+ argument :id,
+ ::Types::GlobalIDType[::Environment],
+ required: true,
+ description: 'Global ID of the environment to update.'
+
+ argument :external_url,
+ GraphQL::Types::String,
+ required: false,
+ description: 'External URL of the environment.'
+
+ argument :tier,
+ Types::DeploymentTierEnum,
+ required: false,
+ description: 'Tier of the environment.'
+
+ argument :cluster_agent_id,
+ ::Types::GlobalIDType[::Clusters::Agent],
+ required: false,
+ description: 'Cluster agent of the environment.'
+
+ field :environment,
+ Types::EnvironmentType,
+ null: true,
+ description: 'Environment after attempt to update.'
+
+ def resolve(id:, **kwargs)
+ environment = authorized_find!(id: id)
+
+ convert_cluster_agent_id(kwargs)
+
+ response = ::Environments::UpdateService.new(environment.project, current_user, kwargs).execute(environment)
+
+ if response.success?
+ { environment: response.payload[:environment], errors: [] }
+ else
+ { environment: response.payload[:environment], errors: response.errors }
+ end
+ end
+
+ private
+
+ def convert_cluster_agent_id(kwargs)
+ return unless kwargs.key?(:cluster_agent_id)
+
+ kwargs[:cluster_agent] = if kwargs[:cluster_agent_id]
+ ::Clusters::Agent.find_by_id(kwargs[:cluster_agent_id].model_id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb
index 0c1acdf316e..c8a4d0aaa86 100644
--- a/app/graphql/mutations/issues/create.rb
+++ b/app/graphql/mutations/issues/create.rb
@@ -81,9 +81,7 @@ module Mutations
def resolve(project_path:, **attributes)
project = authorized_find!(project_path)
params = build_create_issue_params(attributes.merge(author_id: current_user.id), project)
-
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
- result = ::Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: spam_params).execute
+ result = ::Issues::CreateService.new(container: project, current_user: current_user, params: params).execute
check_spam_action_response!(result[:issue]) if result[:issue]
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
index 08578881a13..79216e0f821 100644
--- a/app/graphql/mutations/issues/set_confidential.rb
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -15,11 +15,8 @@ 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(container: project, current_user: current_user, params: { confidential: confidential }, spam_params: spam_params)
+ # Changing confidentiality affects spam checking rules, therefore we need to perform a spam check
+ ::Issues::UpdateService.new(container: project, current_user: current_user, params: { confidential: confidential }, perform_spam_check: true)
.execute(issue)
check_spam_action_response!(issue)
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index b5af048dc07..2a863893cf1 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -41,8 +41,7 @@ module Mutations
args = parse_arguments(args)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
- ::Issues::UpdateService.new(container: project, current_user: current_user, params: args, spam_params: spam_params).execute(issue)
+ ::Issues::UpdateService.new(container: project, current_user: current_user, params: args, perform_spam_check: true).execute(issue)
{
issue: issue,
diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb
index ea72b71715c..96bee693a1e 100644
--- a/app/graphql/mutations/namespace/package_settings/update.rb
+++ b/app/graphql/mutations/namespace/package_settings/update.rb
@@ -8,6 +8,12 @@ module Mutations
include Mutations::ResolvesNamespace
+ description <<~DESC
+ These settings can be adjusted by the group Owner or Maintainer.
+ [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting
+ this to Owners only to match the permissions level in the user interface.
+ DESC
+
authorize :admin_package
argument :namespace_path,
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
index 4c6df2776cc..09b814d903e 100644
--- a/app/graphql/mutations/notes/update/base.rb
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -24,12 +24,9 @@ module Mutations
note_params(note, args)
).execute(note)
- # It's possible for updated_note to be `nil`, in the situation
- # where the note is deleted within `Notes::UpdateService` due to
- # the body of the note only containing Quick Actions.
{
- note: updated_note&.reset,
- errors: updated_note ? errors_on_object(updated_note) : []
+ note: updated_note.destroyed? ? nil : updated_note.reset,
+ errors: updated_note.destroyed? ? [] : errors_on_object(updated_note)
}
end
diff --git a/app/graphql/mutations/projects/sync_fork.rb b/app/graphql/mutations/projects/sync_fork.rb
index 4520f6388c5..6a4ec4a26b8 100644
--- a/app/graphql/mutations/projects/sync_fork.rb
+++ b/app/graphql/mutations/projects/sync_fork.rb
@@ -22,9 +22,6 @@ module Mutations
def resolve(project_path:, target_branch:)
project = authorized_find!(project_path, target_branch)
- return respond(nil, ['Feature flag is disabled']) unless Feature.enabled?(:synchronize_fork,
- project.fork_source)
-
return respond(nil, ['Target branch does not exist']) unless project.repository.branch_exists?(target_branch)
details_resolver = Resolvers::Projects::ForkDetailsResolver.new(object: project, context: context, field: nil)
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 96ac3f8a113..1c7dbfa751d 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -48,8 +48,7 @@ module Mutations
process_args_for_params!(args)
- 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 = ::Snippets::CreateService.new(project: project, current_user: current_user, params: args)
service_response = service.execute
# Only when the user is not an api user and the operation was successful
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 39843a3714a..7faf9cf9019 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -33,8 +33,7 @@ module Mutations
process_args_for_params!(args)
- 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 = ::Snippets::UpdateService.new(project: snippet.project, current_user: current_user, params: args, perform_spam_check: true)
service_response = service.execute(snippet)
# TODO: DRY this up - From here down, this is all duplicated with Mutations::Snippets::Create#resolve, except for
diff --git a/app/graphql/mutations/users/set_namespace_commit_email.rb b/app/graphql/mutations/users/set_namespace_commit_email.rb
new file mode 100644
index 00000000000..72ef0635bb3
--- /dev/null
+++ b/app/graphql/mutations/users/set_namespace_commit_email.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Users
+ class SetNamespaceCommitEmail < BaseMutation
+ graphql_name 'UserSetNamespaceCommitEmail'
+
+ argument :namespace_id,
+ ::Types::GlobalIDType[::Namespace],
+ required: true,
+ description: 'ID of the namespace to set the namespace commit email for.'
+
+ argument :email_id,
+ ::Types::GlobalIDType[::Email],
+ required: false,
+ description: 'ID of the email to set.'
+
+ field :namespace_commit_email,
+ Types::Users::NamespaceCommitEmailType,
+ null: true,
+ description: 'User namespace commit email after mutation.'
+
+ authorize :read_namespace
+
+ def resolve(args)
+ namespace = authorized_find!(args[:namespace_id])
+ args[:email_id] = args[:email_id].model_id
+
+ result = ::Users::SetNamespaceCommitEmailService.new(current_user, namespace, args[:email_id], {}).execute
+ {
+ namespace_commit_email: result.payload[:namespace_commit_email],
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(id)
+ GitlabSchema.object_from_id(
+ id, expected_type: [::Namespace, ::Namespaces::UserNamespace, ::Namespaces::ProjectNamespace]).sync
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/work_items/convert.rb b/app/graphql/mutations/work_items/convert.rb
index 83bca56d900..b1936027fdc 100644
--- a/app/graphql/mutations/work_items/convert.rb
+++ b/app/graphql/mutations/work_items/convert.rb
@@ -27,13 +27,11 @@ module Mutations
work_item_type = find_work_item_type!(attributes[:work_item_type_id])
authorize_work_item_type!(work_item, work_item_type)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
-
update_result = ::WorkItems::UpdateService.new(
container: work_item.project,
current_user: current_user,
params: { work_item_type: work_item_type, issue_type: work_item_type.base_type },
- spam_params: spam_params
+ perform_spam_check: true
).execute(work_item)
check_spam_action_response!(work_item)
diff --git a/app/graphql/mutations/work_items/create.rb b/app/graphql/mutations/work_items/create.rb
index dfd2d5d1f88..9f7b7b5db97 100644
--- a/app/graphql/mutations/work_items/create.rb
+++ b/app/graphql/mutations/work_items/create.rb
@@ -60,7 +60,6 @@ module Mutations
container_path = project_path || namespace_path
container = authorized_find!(container_path)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
params = global_id_compatibility_params(attributes).merge(author_id: current_user.id)
type = ::WorkItems::Type.find(attributes[:work_item_type_id])
widget_params = extract_widget_params!(type, params)
@@ -69,7 +68,6 @@ module Mutations
container: container,
current_user: current_user,
params: params,
- spam_params: spam_params,
widget_params: widget_params
).execute
diff --git a/app/graphql/mutations/work_items/create_from_task.rb b/app/graphql/mutations/work_items/create_from_task.rb
index 23ae09b23fd..bf5c999bf75 100644
--- a/app/graphql/mutations/work_items/create_from_task.rb
+++ b/app/graphql/mutations/work_items/create_from_task.rb
@@ -30,13 +30,10 @@ module Mutations
def resolve(id:, work_item_data:)
work_item = authorized_find!(id: id)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
-
result = ::WorkItems::CreateFromTaskService.new(
work_item: work_item,
current_user: current_user,
- work_item_params: work_item_data,
- spam_params: spam_params
+ work_item_params: work_item_data
).execute
check_spam_action_response!(result[:work_item]) if result[:work_item]
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index 3fd0f5aab62..f22e9bcf393 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -21,7 +21,6 @@ module Mutations
work_item = authorized_find!(id: id)
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
widget_params = extract_widget_params!(work_item.work_item_type, attributes)
interpret_quick_actions!(work_item, current_user, widget_params, attributes)
@@ -31,7 +30,7 @@ module Mutations
current_user: current_user,
params: attributes,
widget_params: widget_params,
- spam_params: spam_params
+ perform_spam_check: true
).execute(work_item)
check_spam_action_response!(work_item)
diff --git a/app/graphql/mutations/work_items/update_task.rb b/app/graphql/mutations/work_items/update_task.rb
index 8dcc4c325ea..d3df235f894 100644
--- a/app/graphql/mutations/work_items/update_task.rb
+++ b/app/graphql/mutations/work_items/update_task.rb
@@ -29,13 +29,11 @@ module Mutations
work_item = authorized_find!(id: id)
task = authorized_find_task!(task_data_hash[:id])
- spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
-
::WorkItems::UpdateService.new(
container: task.project,
current_user: current_user,
params: task_data_hash.except(:id),
- spam_params: spam_params
+ perform_spam_check: true
).execute(task)
check_spam_action_response!(task)
diff --git a/app/graphql/queries/snippet/snippet.query.graphql b/app/graphql/queries/snippet/snippet.query.graphql
index 5c0c7ebaa1b..8712a6f4b01 100644
--- a/app/graphql/queries/snippet/snippet.query.graphql
+++ b/app/graphql/queries/snippet/snippet.query.graphql
@@ -53,6 +53,7 @@ query GetSnippetQuery($ids: [SnippetID!]) {
id
fullPath
webUrl
+ visibility
}
author {
__typename
diff --git a/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb b/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb
new file mode 100644
index 00000000000..230301ca5da
--- /dev/null
+++ b/app/graphql/resolvers/audit_events/audit_event_definitions_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module AuditEvents
+ class AuditEventDefinitionsResolver < BaseResolver
+ type [Types::AuditEvents::DefinitionType], null: false
+
+ def resolve
+ Gitlab::Audit::Type::Definition.definitions.values
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/blobs_resolver.rb b/app/graphql/resolvers/blobs_resolver.rb
index 0b8180dbce7..546eeb76ff5 100644
--- a/app/graphql/resolvers/blobs_resolver.rb
+++ b/app/graphql/resolvers/blobs_resolver.rb
@@ -17,6 +17,10 @@ module Resolvers
required: false,
default_value: nil,
description: 'Commit ref to get the blobs from. Default value is HEAD.'
+ argument :ref_type, Types::RefTypeEnum,
+ required: false,
+ default_value: nil,
+ description: 'Type of ref.'
# We fetch blobs from Gitaly efficiently but it still scales O(N) with the
# number of paths being fetched, so apply a scaling limit to that.
@@ -24,7 +28,7 @@ module Resolvers
super + (args[:paths] || []).size
end
- def resolve(paths:, ref:)
+ def resolve(paths:, ref:, ref_type:)
authorize!(repository.container)
return [] if repository.empty?
@@ -32,7 +36,13 @@ module Resolvers
ref ||= repository.root_ref
validate_ref(ref)
- repository.blobs_at(paths.map { |path| [ref, path] })
+ ref = ExtractsRef.qualify_ref(ref, ref_type)
+
+ repository.blobs_at(paths.map { |path| [ref, path] }).tap do |blobs|
+ blobs.each do |blob|
+ blob.ref_type = ref_type
+ end
+ end
end
private
diff --git a/app/graphql/resolvers/group_environment_scopes_resolver.rb b/app/graphql/resolvers/group_environment_scopes_resolver.rb
new file mode 100644
index 00000000000..61ccb2eefbb
--- /dev/null
+++ b/app/graphql/resolvers/group_environment_scopes_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class GroupEnvironmentScopesResolver < BaseResolver
+ type Types::Ci::GroupEnvironmentScopeType.connection_type, null: true
+
+ alias_method :group, :object
+
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Name of the environment scope.'
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search query for environment scope name.'
+
+ def resolve(**args)
+ return unless group.present?
+
+ ::Groups::EnvironmentScopesFinder.new(group: group, params: args).execute
+ end
+ end
+end
diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb
index 00c43bdfee6..acf7826ab13 100644
--- a/app/graphql/resolvers/last_commit_resolver.rb
+++ b/app/graphql/resolvers/last_commit_resolver.rb
@@ -11,7 +11,8 @@ module Resolvers
def resolve(**args)
# Ensure merge commits can be returned by sending nil to Gitaly instead of '/'
path = tree.path == '/' ? nil : tree.path
- commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path, literal_pathspec: true)
+ commit = Gitlab::Git::Commit.last_for_path(tree.repository,
+ ExtractsRef.qualify_ref(tree.sha, tree.ref_type), path, literal_pathspec: true)
::Commit.new(commit, tree.repository.project) if commit
end
diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb
index 726e78f9971..f0781058bea 100644
--- a/app/graphql/resolvers/namespace_projects_resolver.rb
+++ b/app/graphql/resolvers/namespace_projects_resolver.rb
@@ -7,6 +7,11 @@ module Resolvers
default_value: false,
description: 'Include also subgroup projects.'
+ argument :not_aimed_for_deletion, GraphQL::Types::Boolean,
+ required: false,
+ default_value: false,
+ description: 'Include projects that are not aimed for deletion.'
+
argument :search, GraphQL::Types::String,
required: false,
default_value: nil,
@@ -60,6 +65,7 @@ module Resolvers
def finder_params(args)
{
include_subgroups: args.dig(:include_subgroups),
+ not_aimed_for_deletion: args.dig(:not_aimed_for_deletion),
sort: args.dig(:sort),
search: args.dig(:search),
ids: parse_gids(args.dig(:ids)),
diff --git a/app/graphql/resolvers/noteable/notes_resolver.rb b/app/graphql/resolvers/noteable/notes_resolver.rb
new file mode 100644
index 00000000000..0d25c747ffb
--- /dev/null
+++ b/app/graphql/resolvers/noteable/notes_resolver.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Noteable
+ class NotesResolver < BaseResolver
+ include LooksAhead
+
+ type Types::Notes::NoteType.connection_type, null: false
+
+ before_connection_authorization do |nodes, current_user|
+ next if nodes.blank?
+
+ # For all noteables where we use this resolver, we can assume that all notes will belong to the same project
+ project = nodes.first.project
+
+ ::Preloaders::Projects::NotesPreloader.new(project, current_user).call(nodes)
+ end
+
+ def resolve_with_lookahead(*)
+ apply_lookahead(object.notes.fresh)
+ end
+
+ private
+
+ def unconditional_includes
+ [:author, :project]
+ end
+
+ def preloads
+ {
+ award_emoji: [:award_emoji]
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb
index 8fd80b1a9b9..de48fbafb04 100644
--- a/app/graphql/resolvers/paginated_tree_resolver.rb
+++ b/app/graphql/resolvers/paginated_tree_resolver.rb
@@ -18,6 +18,9 @@ module Resolvers
argument :ref, GraphQL::Types::String,
required: false,
description: 'Commit ref to get the tree for. Default value is HEAD.'
+ argument :ref_type, Types::RefTypeEnum,
+ required: false,
+ description: 'Type of ref.'
alias_method :repository, :object
@@ -25,7 +28,6 @@ module Resolvers
return if repository.empty?
cursor = args.delete(:after)
- args[:ref] ||= :head
pagination_params = {
limit: @field.max_page_size || 100,
@@ -33,9 +35,11 @@ module Resolvers
}
tree = repository.tree(
- args[:ref], args[:path], recursive: args[:recursive],
- skip_flat_paths: false,
- pagination_params: pagination_params
+ args[:ref].presence || :head,
+ args[:path], recursive: args[:recursive],
+ skip_flat_paths: false,
+ pagination_params: pagination_params,
+ ref_type: args[:ref_type]
)
next_cursor = tree.cursor&.next_cursor
diff --git a/app/graphql/resolvers/timelog_resolver.rb b/app/graphql/resolvers/timelog_resolver.rb
index d2b67451698..4f52db6801d 100644
--- a/app/graphql/resolvers/timelog_resolver.rb
+++ b/app/graphql/resolvers/timelog_resolver.rb
@@ -37,7 +37,7 @@ module Resolvers
argument :sort, Types::TimeTracking::TimelogSortEnum,
description: 'List timelogs in a particular order.',
required: false,
- default_value: { field: 'spent_at', direction: :asc }
+ default_value: :spent_at_asc
def resolve_with_lookahead(**args)
validate_args!(object, args)
@@ -144,10 +144,7 @@ module Resolvers
def apply_sorting(timelogs, args)
return timelogs unless args[:sort]
- field = args[:sort][:field]
- direction = args[:sort][:direction]
-
- timelogs.sort_by_field(field, direction)
+ timelogs.sort_by_field(args[:sort])
end
def raise_argument_error(message)
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index 553f9aa6cd9..6b88f120d1b 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -17,14 +17,18 @@ module Resolvers
argument :ref, GraphQL::Types::String,
required: false,
description: 'Commit ref to get the tree for. Default value is HEAD.'
+ argument :ref_type, Types::RefTypeEnum,
+ required: false,
+ description: 'Type of ref.'
alias_method :repository, :object
def resolve(**args)
return unless repository.exists?
- args[:ref] ||= :head
- repository.tree(args[:ref], args[:path], recursive: args[:recursive])
+ ref = (args[:ref].presence || :head)
+
+ repository.tree(ref, args[:path], recursive: args[:recursive], ref_type: args[:ref_type])
end
end
end
diff --git a/app/graphql/subscriptions/work_item_updated.rb b/app/graphql/subscriptions/work_item_updated.rb
new file mode 100644
index 00000000000..f7bb8372e50
--- /dev/null
+++ b/app/graphql/subscriptions/work_item_updated.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Subscriptions
+ class WorkItemUpdated < BaseSubscription
+ include Gitlab::Graphql::Laziness
+
+ payload_type Types::WorkItemType
+
+ argument :work_item_id, Types::GlobalIDType[WorkItem],
+ required: true,
+ description: 'ID of the work item.'
+
+ def authorized?(work_item_id:)
+ work_item = force(GitlabSchema.find_by_gid(work_item_id))
+
+ unauthorized! unless work_item && Ability.allowed?(current_user, :"read_#{work_item.to_ability_name}", work_item)
+
+ true
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index 5784c7a4872..36dd930c3d9 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -144,10 +144,6 @@ module Types
null: false,
description: 'URL of the alert.'
- def notes
- object.ordered_notes
- end
-
def metrics_dashboard_url
return if Feature.enabled?(:remove_monitor_metrics)
diff --git a/app/graphql/types/audit_events/definition_type.rb b/app/graphql/types/audit_events/definition_type.rb
new file mode 100644
index 00000000000..575b99c5815
--- /dev/null
+++ b/app/graphql/types/audit_events/definition_type.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Types
+ module AuditEvents
+ class DefinitionType < ::Types::BaseObject
+ graphql_name 'AuditEventDefinition'
+ description 'Represents the YAML definitions for audit events defined ' \
+ 'in `ee/config/audit_events/types/<event-type-name>.yml` ' \
+ 'and `config/audit_events/types/<event-type-name>.yml`.'
+
+ authorize :audit_event_definitions
+
+ field :name, GraphQL::Types::String,
+ null: false,
+ description: 'Key name of the audit event.'
+
+ field :description, GraphQL::Types::String,
+ null: false,
+ description: 'Description of what action the audit event tracks.'
+
+ field :introduced_by_issue, GraphQL::Types::String,
+ null: true,
+ description: 'Link to the issue introducing the event. For older' \
+ 'audit events, it can be a commit URL rather than a' \
+ 'merge request URL.'
+
+ field :introduced_by_mr, GraphQL::Types::String,
+ null: true,
+ description: 'Link to the merge request introducing the event. For' \
+ 'older audit events, it can be a commit URL rather than' \
+ 'a merge request URL.'
+
+ field :feature_category, GraphQL::Types::String,
+ null: false,
+ description: 'Feature category associated with the event.'
+
+ field :milestone, GraphQL::Types::String,
+ null: false,
+ description: 'Milestone the event was introduced in.'
+
+ field :saved_to_database, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates if the event is saved to PostgreSQL database.'
+
+ field :streamed, GraphQL::Types::Boolean,
+ null: false,
+ description: 'Indicates if the event is streamed to an external destination.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb
deleted file mode 100644
index b5947826fa1..00000000000
--- a/app/graphql/types/ci/catalog/resource_type.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Types
- module Ci
- module Catalog
- # rubocop: disable Graphql/AuthorizeTypes
- class ResourceType < BaseObject
- graphql_name 'CiCatalogResource'
-
- connection_type_class(Types::CountableConnectionType)
-
- field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.',
- alpha: { milestone: '15.11' }
-
- field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.',
- alpha: { milestone: '15.11' }
-
- field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.',
- alpha: { milestone: '15.11' }
-
- field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.',
- method: :avatar_path, alpha: { milestone: '15.11' }
- end
- # rubocop: enable Graphql/AuthorizeTypes
- end
- end
-end
diff --git a/app/graphql/types/ci/group_environment_scope_connection_type.rb b/app/graphql/types/ci/group_environment_scope_connection_type.rb
new file mode 100644
index 00000000000..ddbc00d3870
--- /dev/null
+++ b/app/graphql/types/ci/group_environment_scope_connection_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class GroupEnvironmentScopeConnectionType < GraphQL::Types::Relay::BaseConnection
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/group_environment_scope_type.rb b/app/graphql/types/ci/group_environment_scope_type.rb
new file mode 100644
index 00000000000..3a3a5a3f59f
--- /dev/null
+++ b/app/graphql/types/ci/group_environment_scope_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class GroupEnvironmentScopeType < BaseObject
+ graphql_name 'CiGroupEnvironmentScope'
+ description 'Ci/CD environment scope for a group.'
+
+ connection_type_class(Types::Ci::GroupEnvironmentScopeConnectionType)
+
+ field :name, GraphQL::Types::String,
+ null: true,
+ description: 'Scope name defininig the enviromnments that can use the variable.'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb
index 6346d50de3a..2280fcd1370 100644
--- a/app/graphql/types/ci/job_artifact_type.rb
+++ b/app/graphql/types/ci/job_artifact_type.rb
@@ -19,7 +19,7 @@ module Types
description: 'File name of the artifact.',
method: :filename
- field :size, GraphQL::Types::Int, null: false,
+ field :size, GraphQL::Types::BigInt, null: false,
description: 'Size of the artifact in bytes.'
field :expire_at, Types::TimeType, null: true,
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index e77c2a38608..a779ceb2e2a 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -263,3 +263,5 @@ module Types
end
end
end
+
+Types::Ci::JobType.prepend_mod_with('Types::Ci::JobType')
diff --git a/app/graphql/types/ci/runner_manager_type.rb b/app/graphql/types/ci/runner_manager_type.rb
index 2a5053f8f07..9c89b6537ea 100644
--- a/app/graphql/types/ci/runner_manager_type.rb
+++ b/app/graphql/types/ci/runner_manager_type.rb
@@ -47,3 +47,5 @@ module Types
end
end
end
+
+Types::Ci::RunnerManagerType.prepend_mod_with('Types::Ci::RunnerManagerType')
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index a3737cbcd0d..936ad52200c 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -79,6 +79,13 @@ module Types
null: true,
description: 'Deployment freeze periods of the environment.'
+ field :cluster_agent,
+ Types::Clusters::AgentType,
+ description: 'Cluster agent of the environment.',
+ null: true do
+ extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
+ end
+
def tier
object.tier.to_sym
end
diff --git a/app/graphql/types/global_id_type.rb b/app/graphql/types/global_id_type.rb
index 7ebd98ff2e7..295a20c645e 100644
--- a/app/graphql/types/global_id_type.rb
+++ b/app/graphql/types/global_id_type.rb
@@ -7,7 +7,11 @@ module Types
A global identifier.
A global identifier represents an object uniquely across the application.
- An example of such an identifier is `"gid://gitlab/User/1"`.
+ An example of a global identifier is `"gid://gitlab/User/1"`.
+
+ `gid://gitlab` stands for the root name.
+ `User` is the name of the ActiveRecord class of the record.
+ `1` is the record id as per the id in the db table.
Global identifiers are encoded as strings.
DESC
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index da2c06d04b7..5fd6ee948d3 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -83,6 +83,12 @@ module Types
description: 'Merge requests for projects in this group.',
resolver: Resolvers::GroupMergeRequestsResolver
+ field :environment_scopes,
+ Types::Ci::GroupEnvironmentScopeType.connection_type,
+ description: 'Environment scopes of the group.',
+ null: true,
+ resolver: Resolvers::GroupEnvironmentScopesResolver
+
field :milestones,
description: 'Milestones of the group.',
extras: [:lookahead],
@@ -170,8 +176,14 @@ module Types
field :dependency_proxy_total_size_in_bytes,
GraphQL::Types::Int,
null: false,
+ deprecated: { reason: 'Use `dependencyProxyTotalSizeBytes`', milestone: '16.1' },
description: 'Total size of the dependency proxy cached images in bytes.'
+ field :dependency_proxy_total_size_bytes,
+ GraphQL::Types::BigInt,
+ null: false,
+ description: 'Total size of the dependency proxy cached images in bytes, encoded as a string.'
+
field :dependency_proxy_image_prefix,
GraphQL::Types::String,
null: false,
@@ -289,6 +301,10 @@ module Types
end
def dependency_proxy_total_size_in_bytes
+ dependency_proxy_total_size_bytes
+ end
+
+ def dependency_proxy_total_size_bytes
group.dependency_proxy_manifests.sum(:size) + group.dependency_proxy_blobs.sum(:size)
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 7e436d74dcf..16c46d172f3 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -9,6 +9,7 @@ module Types
mount_mutation Mutations::Achievements::Award, alpha: { milestone: '15.10' }
mount_mutation Mutations::Achievements::Create, alpha: { milestone: '15.8' }
mount_mutation Mutations::Achievements::Delete, alpha: { milestone: '15.11' }
+ mount_mutation Mutations::Achievements::DeleteUserAchievement, alpha: { milestone: '16.1' }
mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' }
mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' }
mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs
@@ -52,7 +53,10 @@ module Types
mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
mount_mutation Mutations::DependencyProxy::GroupSettings::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
+ mount_mutation Mutations::Environments::Create
+ mount_mutation Mutations::Environments::Delete
mount_mutation Mutations::Environments::Stop
+ mount_mutation Mutations::Environments::Update
mount_mutation Mutations::IncidentManagement::TimelineEvent::Create, alpha: { milestone: '15.6' }
mount_mutation Mutations::IncidentManagement::TimelineEvent::PromoteFromNote
mount_mutation Mutations::IncidentManagement::TimelineEvent::Update
@@ -139,11 +143,6 @@ module Types
mount_mutation Mutations::Ci::PipelineSchedule::Play
mount_mutation Mutations::Ci::PipelineSchedule::Create
mount_mutation Mutations::Ci::PipelineSchedule::Update
- mount_mutation Mutations::Ci::CiCdSettingsUpdate, deprecated: {
- reason: :renamed,
- replacement: 'ProjectCiCdSettingsUpdate',
- milestone: '15.0'
- }
mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate
mount_mutation Mutations::Ci::Job::ArtifactsDestroy
mount_mutation Mutations::Ci::Job::Play
@@ -183,6 +182,7 @@ module Types
mount_mutation Mutations::Pages::MarkOnboardingComplete
mount_mutation Mutations::SavedReplies::Destroy
mount_mutation Mutations::Uploads::Delete
+ mount_mutation Mutations::Users::SetNamespaceCommitEmail
end
end
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 5055facb21b..eb1963f976a 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -11,6 +11,11 @@ module Types
implements(Types::ResolvableInterface)
+ field :max_access_level_of_author, GraphQL::Types::String,
+ null: true,
+ description: "Max access level of the note author in the project.",
+ method: :human_max_access
+
field :id, ::Types::GlobalIDType[::Note],
null: false,
description: 'ID of the note.'
@@ -36,6 +41,10 @@ module Types
method: :note,
description: 'Content of the note.'
+ field :award_emoji, Types::AwardEmojis::AwardEmojiType.connection_type,
+ null: true,
+ description: 'List of award emojis associated with the note.'
+
field :confidential, GraphQL::Types::Boolean,
null: true,
description: 'Indicates if this note is confidential.',
@@ -74,6 +83,12 @@ module Types
null: true,
description: 'User who last edited the note.'
+ field :author_is_contributor, GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates whether the note author is a contributor.',
+ method: :contributor?,
+ calls_gitaly: true
+
field :system_note_metadata, Types::Notes::SystemNoteMetadataType,
null: true,
description: 'Metadata for the given note if it is a system note.'
diff --git a/app/graphql/types/notes/noteable_interface.rb b/app/graphql/types/notes/noteable_interface.rb
index 537084dff62..9971511d6ce 100644
--- a/app/graphql/types/notes/noteable_interface.rb
+++ b/app/graphql/types/notes/noteable_interface.rb
@@ -5,7 +5,7 @@ module Types
module NoteableInterface
include Types::BaseInterface
- field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes on this noteable."
+ field :notes, resolver: Resolvers::Noteable::NotesResolver, null: false, description: "All notes on this noteable."
field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable."
field :commenters, Types::UserType.connection_type, null: false, description: "All commenters on this noteable."
diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb
index 0b6a384ec0e..d9946fc4ea6 100644
--- a/app/graphql/types/permission_types/work_item.rb
+++ b/app/graphql/types/permission_types/work_item.rb
@@ -7,7 +7,8 @@ module Types
description 'Check permissions for the current user on a work item'
abilities :read_work_item, :update_work_item, :delete_work_item,
- :admin_work_item, :admin_parent_link, :set_work_item_metadata
+ :admin_work_item, :admin_parent_link, :set_work_item_metadata,
+ :create_note
end
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 20dce54d740..b26e447f622 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -165,6 +165,12 @@ module Types
alpha: { milestone: '15.1' },
description: 'Find a work item.'
+ field :audit_event_definitions,
+ Types::AuditEvents::DefinitionType.connection_type,
+ null: false,
+ description: 'Definitions for all audit events available on the instance.',
+ resolver: Resolvers::AuditEvents::AuditEventDefinitionsResolver
+
def design_management
DesignManagementObject.new(nil)
end
diff --git a/app/graphql/types/ref_type_enum.rb b/app/graphql/types/ref_type_enum.rb
new file mode 100644
index 00000000000..f56d4cd512a
--- /dev/null
+++ b/app/graphql/types/ref_type_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ class RefTypeEnum < BaseEnum
+ graphql_name 'RefType'
+ description 'Type of ref'
+
+ value 'HEADS', description: 'Ref type for branches.', value: 'heads'
+ value 'TAGS', description: 'Ref type for tags.', value: 'tags'
+ end
+end
diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb
index 33fc0cbe20e..7f33f77ec14 100644
--- a/app/graphql/types/subscription_type.rb
+++ b/app/graphql/types/subscription_type.rb
@@ -47,6 +47,11 @@ module Types
description: 'Triggered when a note is updated.',
alpha: { milestone: '15.9' }
+ field :work_item_updated,
+ subscription: Subscriptions::WorkItemUpdated,
+ null: true,
+ description: 'Triggered when a work item is updated.'
+
field :merge_request_reviewers_updated,
subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the reviewers of a merge request are updated.'
diff --git a/app/graphql/types/time_tracking/timelog_sort_enum.rb b/app/graphql/types/time_tracking/timelog_sort_enum.rb
index ad21c084d78..40b9e0cfb67 100644
--- a/app/graphql/types/time_tracking/timelog_sort_enum.rb
+++ b/app/graphql/types/time_tracking/timelog_sort_enum.rb
@@ -6,16 +6,10 @@ module Types
graphql_name 'TimelogSort'
description 'Values for sorting timelogs'
- sortable_fields = ['Spent at', 'Time spent']
-
- sortable_fields.each do |field|
- value "#{field.upcase.tr(' ', '_')}_ASC",
- value: { field: field.downcase.tr(' ', '_'), direction: :asc },
- description: "#{field} by ascending order."
- value "#{field.upcase.tr(' ', '_')}_DESC",
- value: { field: field.downcase.tr(' ', '_'), direction: :desc },
- description: "#{field} by descending order."
- end
+ value 'SPENT_AT_ASC', 'Spent at ascending order.', value: :spent_at_asc
+ value 'SPENT_AT_DESC', 'Spent at descending order.', value: :spent_at_desc
+ value 'TIME_SPENT_ASC', 'Time spent ascending order.', value: :time_spent_asc
+ value 'TIME_SPENT_DESC', 'Time spent descending order.', value: :time_spent_desc
end
end
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index b4950cc60e3..5357f2f8e66 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -162,6 +162,41 @@ module Types
extras: [:lookahead],
resolver: ::Resolvers::Achievements::UserAchievementsResolver
+ field :bio,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Bio of the user.'
+
+ field :linkedin,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'LinkedIn profile name of the user.'
+
+ field :twitter,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Twitter username of the user.'
+
+ field :discord,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Discord ID of the user.'
+
+ field :organization,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Who the user represents or works for.'
+
+ field :job_title,
+ type: ::GraphQL::Types::String,
+ null: true,
+ description: 'Job title of the user.'
+
+ field :created_at,
+ type: Types::TimeType,
+ null: true,
+ description: 'Timestamp of when the user was created.'
+
definition_methods do
def resolve_type(object, context)
# in the absence of other information, we cannot tell - just default to
diff --git a/app/helpers/admin/application_settings/settings_helper.rb b/app/helpers/admin/application_settings/settings_helper.rb
index 1741d6a953a..0a7f20caa02 100644
--- a/app/helpers/admin/application_settings/settings_helper.rb
+++ b/app/helpers/admin/application_settings/settings_helper.rb
@@ -15,6 +15,55 @@ module Admin
def project_missing_pipeline_yaml?(project)
project.repository&.gitlab_ci_yml.blank?
end
+
+ def code_suggestions_token_explanation
+ link_start = code_suggestions_link_start(code_suggestions_pat_docs_url)
+
+ # rubocop:disable Layout/LineLength
+ # rubocop:disable Style/FormatString
+ s_('CodeSuggestionsSM|Your personal access token from GitLab.com. See the %{link_start}documentation%{link_end} for information on creating a personal access token.')
+ .html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+ # rubocop:enable Style/FormatString
+ # rubocop:enable Layout/LineLength
+ end
+
+ def code_suggestions_agreement
+ terms_link_start = code_suggestions_link_start(code_suggestions_agreement_url)
+ ai_docs_link_start = code_suggestions_link_start(code_suggestions_ai_docs_url)
+
+ # rubocop:disable Layout/LineLength
+ # rubocop:disable Style/FormatString
+ s_('CodeSuggestionsSM|&#8226; Agree to the %{terms_link_start}GitLab Testing Agreement%{link_end}.%{br} &#8226; Acknowledge that GitLab will send data from the instance, including personal data, to Google for cloud hosting.%{br} &nbsp;&nbsp;&nbsp;We may also send data to %{ai_docs_link_start}third-party AI providers%{link_end} to provide this feature.')
+ .html_safe % { terms_link_start: terms_link_start, ai_docs_link_start: ai_docs_link_start, link_end: '</a>'.html_safe, br: '</br>'.html_safe }
+ # rubocop:enable Style/FormatString
+ # rubocop:enable Layout/LineLength
+ end
+
+ private
+
+ # rubocop:disable Gitlab/DocUrl
+ # We want to link SaaS docs for flexibility for every URL related to Code Suggestions on Self Managed.
+ # We expect to update docs often during the Beta and we want to point user to the most up to date information.
+ def code_suggestions_docs_url
+ 'https://docs.gitlab.com/ee/user/project/repository/code_suggestions.html'
+ end
+
+ def code_suggestions_agreement_url
+ 'https://about.gitlab.com/handbook/legal/testing-agreement/'
+ end
+
+ def code_suggestions_ai_docs_url
+ 'https://docs.gitlab.com/ee/user/ai_features.html'
+ end
+
+ def code_suggestions_pat_docs_url
+ 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token'
+ end
+ # rubocop:enable Gitlab/DocUrl
+
+ def code_suggestions_link_start(url)
+ "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\">".html_safe
+ end
end
end
end
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index e9465e0db22..5beefbb943c 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -53,8 +53,12 @@ module AppearancesHelper
image_path('logo.svg')
end
- def brand_text
- markdown_field(current_appearance, :description)
+ def custom_sign_in_description
+ [
+ markdown_field(current_appearance, :description),
+ markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text),
+ markdown(Gitlab::CurrentSettings.help_text)
+ ].compact_blank.join("<br>").html_safe
end
def brand_new_project_guidelines
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 71f8478544b..7f1c28de8a7 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -191,16 +191,17 @@ module ApplicationHelper
}
end
- def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false)
- return unless object.edited?
+ def edited_time_ago_with_tooltip(editable_object, placement: 'top', html_class: 'time_ago', exclude_author: false)
+ return unless editable_object.edited?
content_tag :small, class: 'edited-text' do
- output = content_tag(:span, 'Edited ')
- output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class)
+ timeago = time_ago_with_tooltip(editable_object.last_edited_at, placement: placement, html_class: html_class)
- if !exclude_author && object.last_edited_by
- output << content_tag(:span, ' by ')
- output << link_to_member(object.project, object.last_edited_by, avatar: false, extra_class: 'gl-hover-text-decoration-underline', author_class: nil)
+ if !exclude_author && editable_object.last_edited_by
+ author_link = link_to_member(editable_object.project, editable_object.last_edited_by, avatar: false, extra_class: 'gl-hover-text-decoration-underline', author_class: nil)
+ output = safe_format(_("Edited %{timeago} by %{author}"), timeago: timeago, author: author_link)
+ else
+ output = safe_format(_("Edited %{timeago}"), timeago: timeago)
end
output
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index dab682d88e0..adbf7ab7cf2 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -99,11 +99,11 @@ module ApplicationSettingsHelper
checked_value: level,
unchecked_value: nil
) do |c|
- c.label do
+ c.with_label do
visibility_level_icon(level) + content_tag(:span, label, { class: 'gl-ml-2' })
end
- c.help_text do
+ c.with_help_text do
restricted_visibility_levels_help_text.fetch(level)
end
end
@@ -218,6 +218,7 @@ module ApplicationSettingsHelper
:admin_mode,
:after_sign_out_path,
:after_sign_up_text,
+ :ai_access_token,
:akismet_api_key,
:akismet_enabled,
:allow_local_requests_from_hooks_and_services,
@@ -309,6 +310,7 @@ module ApplicationSettingsHelper
:inactive_projects_delete_after_months,
:inactive_projects_min_size_mb,
:inactive_projects_send_warning_email_after_months,
+ :instance_level_code_suggestions_enabled,
:invisible_captcha_enabled,
:jira_connect_application_key,
:jira_connect_public_key_storage_enabled,
@@ -337,6 +339,8 @@ module ApplicationSettingsHelper
:kroki_formats,
:plantuml_enabled,
:plantuml_url,
+ :diagramsnet_enabled,
+ :diagramsnet_url,
:polling_interval_multiplier,
:project_export_enabled,
:prometheus_metrics_enabled,
@@ -450,6 +454,7 @@ module ApplicationSettingsHelper
:group_export_limit,
:group_download_export_limit,
:wiki_page_max_content_bytes,
+ :wiki_asciidoc_allow_uri_includes,
:container_registry_delete_tags_service_timeout,
:rate_limiting_response_text,
:package_registry_cleanup_policies_worker_capacity,
@@ -491,7 +496,8 @@ module ApplicationSettingsHelper
:deactivation_email_additional_text,
:projects_api_rate_limit_unauthenticated,
:gitlab_dedicated_instance,
- :ci_max_includes
+ :ci_max_includes,
+ :allow_account_deletion
].tap do |settings|
next if Gitlab.com?
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 0ee08ba1820..0feaee2bd93 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -16,6 +16,7 @@ module AuthHelper
jwt
openid_connect
salesforce
+ shibboleth
twitter
).freeze
LDAP_PROVIDER = /\Aldap/.freeze
@@ -51,7 +52,8 @@ module AuthHelper
{
saml: 'saml_login_button',
openid_connect: 'oidc_login_button',
- github: 'github_login_button'
+ github: 'github_login_button',
+ gitlab: 'gitlab_oauth_login_button'
}[provider.to_sym]
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 17f995ec0ad..d62498aea0b 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -27,11 +27,17 @@ module AvatarsHelper
end
end
- def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
+ def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true, by_commit_email: false)
return default_avatar if email.blank?
Gitlab::AvatarCache.by_email(email, size, scale, only_path) do
- avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path: only_path)
+ avatar_icon_by_user_email_or_gravatar(
+ email,
+ size,
+ scale,
+ only_path: only_path,
+ by_commit_email: by_commit_email
+ )
end
end
@@ -115,8 +121,13 @@ module AvatarsHelper
private
- def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:)
- user = User.with_public_email(email).first
+ def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:, by_commit_email: false)
+ user =
+ if by_commit_email
+ User.find_by_any_email(email)
+ else
+ User.with_public_email(email).first
+ end
if user
avatar_icon_for_user(user, size, scale, only_path: only_path)
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 02f69327dff..be9306ce80b 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -141,10 +141,6 @@ module BlobHelper
@gitlab_ci_ymls ||= TemplateFinder.all_template_names(project, :gitlab_ci_ymls)
end
- def metrics_dashboard_ymls(project)
- @metrics_dashboard_ymls ||= TemplateFinder.all_template_names(project, :metrics_dashboard_ymls)
- end
-
def dockerfile_names(project)
@dockerfile_names ||= TemplateFinder.all_template_names(project, :dockerfiles)
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index a500a695029..9fadd5ece14 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -20,6 +20,27 @@ module BranchesHelper
end
end
end
+
+ def merge_request_status(merge_request)
+ return unless merge_request.present?
+ return if merge_request.closed?
+
+ if merge_request.open? || merge_request.locked?
+ variant = :success
+ variant = :warning if merge_request.draft?
+
+ mr_icon = 'merge-request-open'
+ mr_status = _('Open')
+ elsif merge_request.merged?
+ variant = :info
+ mr_icon = 'merge'
+ mr_status = _('Merged')
+ else
+ return
+ end
+
+ { icon: mr_icon, title: "#{mr_status} - #{merge_request.title}", variant: variant }
+ end
end
BranchesHelper.prepend_mod_with('BranchesHelper')
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 2f14c907b12..a62ffa144f1 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -95,7 +95,8 @@ module BroadcastMessagesHelper
target_path: broadcast_message.target_path,
starts_at: broadcast_message.starts_at.iso8601,
ends_at: broadcast_message.ends_at.iso8601,
- target_access_level_options: target_access_level_options.to_json
+ target_access_level_options: target_access_level_options.to_json,
+ show_in_cli: broadcast_message.show_in_cli.to_s
}
end
diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb
index 9f70410f17f..bc77e0cd33a 100644
--- a/app/helpers/ci/catalog/resources_helper.rb
+++ b/app/helpers/ci/catalog/resources_helper.rb
@@ -3,6 +3,10 @@
module Ci
module Catalog
module ResourcesHelper
+ def can_add_catalog_resource?(_project)
+ false
+ end
+
def can_view_namespace_catalog?(_project)
false
end
diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb
index 6b15f0c9e20..b222ca5538d 100644
--- a/app/helpers/ci/pipelines_helper.rb
+++ b/app/helpers/ci/pipelines_helper.rb
@@ -93,7 +93,7 @@ module Ci
pipeline_schedule_url: pipeline_schedules_path(project),
empty_state_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'),
error_state_svg_path: image_path('illustrations/pipelines_failed.svg'),
- no_pipelines_svg_path: image_path('illustrations/pipelines_pending.svg'),
+ no_pipelines_svg_path: image_path('illustrations/empty-state/empty-pipeline-md.svg'),
can_create_pipeline: can?(current_user, :create_pipeline, project).to_s,
new_pipeline_path: can?(current_user, :create_pipeline, project) && new_project_pipeline_path(project),
ci_lint_path: can?(current_user, :create_pipeline, project) && project_ci_lint_path(project),
@@ -101,7 +101,8 @@ module Ci
has_gitlab_ci: has_gitlab_ci?(project).to_s,
pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project),
suggested_ci_templates: suggested_ci_templates.to_json,
- full_path: project.full_path
+ full_path: project.full_path,
+ visibility_pipeline_id_type: visibility_pipeline_id_type
}
experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e|
@@ -114,6 +115,12 @@ module Ci
data
end
+ def visibility_pipeline_id_type
+ return 'id' unless current_user.present?
+
+ current_user.user_preference.visibility_pipeline_id_type
+ end
+
private
def warning_markdown(pipeline)
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 7177ddd3f31..b1481f668bb 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -22,12 +22,13 @@ module Ci
icon = 'warning-solid'
when :offline
title = s_("Runners|Runner is offline; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) }
- icon = 'status-failed'
- span_class = 'gl-text-red-500'
+ icon = 'status-waiting'
+ span_class = 'gl-text-gray-500'
when :stale
# runner may have contacted (or not) and be stale: consider both cases.
title = contacted_at ? s_("Runners|Runner is stale; last contact was %{runner_contact} ago") % { runner_contact: time_ago_in_words(contacted_at) } : s_("Runners|Runner is stale; it has never contacted this instance")
- icon = 'warning-solid'
+ icon = 'time-out'
+ span_class = 'gl-text-orange-500'
end
content_tag(:span, class: span_class, title: title, data: { toggle: 'tooltip', container: 'body', testid: 'runner_status_icon', qa_selector: "runner_status_#{status}_content" }) do
diff --git a/app/helpers/ci/secure_files_helper.rb b/app/helpers/ci/secure_files_helper.rb
index fca89ddab1e..c4cc178f930 100644
--- a/app/helpers/ci/secure_files_helper.rb
+++ b/app/helpers/ci/secure_files_helper.rb
@@ -3,6 +3,7 @@ module Ci
module SecureFilesHelper
def show_secure_files_setting(project, user)
return false if user.nil?
+ return false unless Gitlab.config.ci_secure_files.enabled
user.can?(:read_secure_files, project)
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index b1d61474700..458d81b3401 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -55,12 +55,6 @@ module ClustersHelper
case tab
when 'environments'
render_if_exists 'clusters/clusters/environments'
- when 'health'
- if Feature.enabled?(:remove_monitor_metrics)
- render('details', expanded: expanded)
- else
- render_if_exists 'clusters/clusters/health'
- end
when 'apps'
render 'applications'
when 'integrations'
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index ed8cca20241..3d0b899e867 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -37,7 +37,7 @@ module FormHelper
dismissible: false,
alert_options: { id: 'error_explanation', class: 'gl-mb-5' }
) do |c|
- c.body do
+ c.with_body do
tag.ul(class: 'gl-pl-5 gl-mb-0') do
messages
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 2ced1bec5e9..a4f463a23be 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -85,6 +85,7 @@ module GroupsHelper
end
end
+ # Overridden in EE
def remove_group_message(group)
_("You are going to remove %{group_name}. This will also delete all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
{ group_name: group.name }
@@ -128,7 +129,8 @@ module GroupsHelper
{
parent_group_url: group.parent && group_url(group.parent),
parent_group_name: group.parent&.name,
- import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane')
+ import_existing_group_path: new_group_path(parent_id: group.parent_id, anchor: 'import-group-pane'),
+ is_saas: Gitlab.com?.to_s
}
end
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 448909543c4..696790b9dcb 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -8,8 +8,8 @@ module IdeHelper
'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'),
'sign-in-path' => new_session_path(current_user),
'user-preferences-path' => profile_preferences_path,
- 'editor-font-src-url' => font_url('jetbrains-mono/JetBrainsMono.woff2'),
- 'editor-font-family' => 'JetBrains Mono',
+ 'editor-font-src-url' => font_url('gitlab-mono/GitLabMono.woff2'),
+ 'editor-font-family' => 'GitLab Mono',
'editor-font-format' => 'woff2'
}.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project))
@@ -29,20 +29,24 @@ module IdeHelper
private
+ def new_ide_code_suggestions_data
+ {}
+ end
+
def new_ide_data(project:)
{
'project-path' => project&.path_with_namespace,
'csp-nonce' => content_security_policy_nonce,
# We will replace these placeholders in the FE
'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path')
- }
+ }.merge(new_ide_code_suggestions_data)
end
def legacy_ide_data(project:)
{
'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'),
'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'),
- 'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
+ 'committed-state-svg-path' => image_path('illustrations/rocket-launch-md.svg'),
'pipelines-empty-state-svg-path': image_path('illustrations/empty-state/empty-pipeline-md.svg'),
'switch-editor-svg-path': image_path('illustrations/rocket-launch-md.svg'),
'promotion-svg-path': image_path('illustrations/web-ide_promotion.svg'),
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index 5471109e6d5..ffea23bf55d 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -136,6 +136,11 @@ module IntegrationsHelper
form_data[:jira_issue_transition_id] = integration.jira_issue_transition_id
end
+ if integration.is_a?(::Integrations::GitlabSlackApplication)
+ form_data[:upgrade_slack_url] = add_to_slack_link(project, slack_app_id)
+ form_data[:should_upgrade_slack] = integration.upgrade_needed?.to_s
+ end
+
form_data
end
@@ -212,6 +217,28 @@ module IntegrationsHelper
event_i18n_map[event] || event.to_s.humanize
end
+ def add_to_slack_link(project, slack_app_id)
+ query = {
+ scope: SlackIntegration::SCOPES.join(','),
+ client_id: slack_app_id,
+ redirect_uri: slack_auth_project_settings_slack_url(project),
+ state: form_authenticity_token
+ }
+
+ "#{::Projects::SlackApplicationInstallService::SLACK_AUTHORIZE_URL}?#{query.to_query}"
+ end
+
+ def gitlab_slack_application_data(projects)
+ {
+ projects: (projects || []).to_json(only: [:id, :name], methods: [:avatar_url, :name_with_namespace]),
+ sign_in_path: new_session_path(:user, redirect_to_referer: 'yes'),
+ is_signed_in: current_user.present?.to_s,
+ slack_link_path: slack_link_profile_slack_path,
+ gitlab_logo_path: image_path('illustrations/gitlab_logo.svg'),
+ slack_logo_path: image_path('illustrations/slack_logo.svg')
+ }
+ end
+
extend self
private
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index e986b56fde4..422380f3cc6 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -45,7 +45,7 @@ module InviteMembersHelper
full_path: source.full_path
}
- if current_user && show_invite_members_for_task?(source)
+ if current_user && show_invite_members_for_task?
dataset.merge!(
tasks_to_be_done_options: tasks_to_be_done_options.to_json,
projects: projects_for_source(source).to_json,
@@ -71,8 +71,7 @@ module InviteMembersHelper
{}
end
- # Overridden in EE
- def show_invite_members_for_task?(_source)
+ def show_invite_members_for_task?
params[:open_modal] == 'invite_members_for_task'
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 3796d8f0210..e247577aed0 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -159,7 +159,6 @@ module IssuablesHelper
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline-block")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
- author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1')
author_output << issuable_meta_author_status(issuable.author)
author_output
@@ -176,11 +175,6 @@ module IssuablesHelper
output.join.html_safe
end
- # This is a dummy method, and has an override defined in ee
- def issuable_meta_author_slot(author, css_class: nil)
- nil
- end
-
def issuables_state_counter_text(issuable_type, state, display_count)
titles = {
opened: _("Open"),
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index fae8d86098e..341c50abf84 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -186,7 +186,7 @@ module IssuesHelper
{
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
- empty_state_svg_path: image_path('illustrations/issues.svg'),
+ empty_state_svg_path: image_path('illustrations/empty-state/empty-issues-md.svg'),
full_path: namespace.full_path,
initial_sort: current_user&.user_preference&.issues_sort,
is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
@@ -241,7 +241,7 @@ module IssuesHelper
calendar_path: url_for(safe_params.merge(calendar_url_options)),
dashboard_labels_path: dashboard_labels_path(format: :json, include_ancestor_groups: true),
dashboard_milestones_path: dashboard_milestones_path(format: :json),
- empty_state_with_filter_svg_path: image_path('illustrations/issues.svg'),
+ empty_state_with_filter_svg_path: image_path('illustrations/empty-state/empty-issues-md.svg'),
empty_state_without_filter_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'),
initial_sort: current_user&.user_preference&.issues_sort,
is_public_visibility_restricted:
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 29f94adcc78..42ffe338367 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -38,7 +38,7 @@ module MembersHelper
def leave_confirmation_message(member_source)
"Are you sure you want to leave the " \
- "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?"
+ "\"#{member_source.human_name}\" #{member_source.model_name.to_s.humanize(capitalize: false)}?"
end
def filter_group_project_member_path(options = {})
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 4f30b555ba0..c7864c1d45f 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -87,8 +87,6 @@ module NavHelper
end
def show_super_sidebar?(user = current_user)
- return false unless Feature.enabled?(:super_sidebar_nav, user)
-
# The new sidebar is not enabled for anonymous use
# Once we enable the new sidebar by default, this
# should return true
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index f2fa82aebdb..656d35e927d 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -46,7 +46,8 @@ module PreferencesHelper
[
[s_('ProjectView|Files and Readme (default)'), :files],
[s_('ProjectView|Activity'), :activity],
- [s_('ProjectView|Readme'), :readme]
+ [s_('ProjectView|Readme'), :readme],
+ [s_('ProjectView|Wiki'), :wiki]
]
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 979b979fba7..26463003f8d 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -68,6 +68,11 @@ module ProfilesHelper
def ssh_key_expiration_policy_enabled?
false
end
+
+ # Overridden in EE::ProfilesHelper#prevent_delete_account?
+ def prevent_delete_account?
+ false
+ end
end
ProfilesHelper.prepend_mod
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index fc4ad10db21..b21bac9da7f 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -9,6 +9,7 @@ module Projects::ErrorTrackingHelper
'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s,
'enable-error-tracking-link' => project_settings_operations_path(project),
'error-tracking-enabled' => error_tracking_enabled.to_s,
+ 'integrated-error-tracking-enabled' => integrated_tracking_enabled?(project).to_s,
'project-path' => project.full_path,
'list-path' => project_error_tracking_index_path(project),
'illustration-path' => image_path('illustrations/cluster_popover.svg'),
@@ -24,15 +25,25 @@ module Projects::ErrorTrackingHelper
'project-path' => project.full_path,
'issue-update-path' => update_project_error_tracking_index_path(*opts),
'project-issues-path' => project_issues_path(project),
- 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
+ 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts),
+ 'integrated-error-tracking-enabled' => integrated_tracking_enabled?(project).to_s
}
end
private
+ # Should show the alert if the FF was turned off after the integrated client has been configured.
def show_integrated_tracking_disabled_alert?(project)
return false if ::Feature.enabled?(:integrated_error_tracking, project)
+ integrated_client_enabled?(project)
+ end
+
+ def integrated_tracking_enabled?(project)
+ ::Feature.enabled?(:integrated_error_tracking, project) && integrated_client_enabled?(project)
+ end
+
+ def integrated_client_enabled?(project)
setting ||= project.error_tracking_setting ||
project.build_error_tracking_setting
diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb
index 0239253d8f0..caebbd5250e 100644
--- a/app/helpers/projects/pipeline_helper.rb
+++ b/app/helpers/projects/pipeline_helper.rb
@@ -24,6 +24,30 @@ module Projects
tests_count: pipeline.test_report_summary.total[:count]
}
end
+
+ def js_pipeline_details_header_data(project, pipeline)
+ {
+ full_path: project.full_path,
+ graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
+ pipeline_iid: pipeline.iid,
+ pipelines_path: project_pipelines_path(project),
+ name: pipeline.name,
+ total_jobs: pipeline.total_size,
+ yaml_errors: pipeline.yaml_errors,
+ failure_reason: pipeline.failure_reason,
+ triggered_by_path: pipeline.child? ? pipeline_path(pipeline.triggered_by_pipeline) : '',
+ schedule: pipeline.schedule?.to_s,
+ child: pipeline.child?.to_s,
+ latest: pipeline.latest?.to_s,
+ merge_train_pipeline: pipeline.merge_train_pipeline?.to_s,
+ invalid: pipeline.has_yaml_errors?.to_s,
+ failed: pipeline.failure_reason?.to_s,
+ auto_devops: pipeline.auto_devops_source?.to_s,
+ detached: pipeline.detached_merge_request_pipeline?.to_s,
+ stuck: pipeline.stuck?,
+ ref_text: pipeline.ref_text
+ }
+ end
end
end
diff --git a/app/helpers/projects/topics_helper.rb b/app/helpers/projects/topics_helper.rb
new file mode 100644
index 00000000000..dadce693d42
--- /dev/null
+++ b/app/helpers/projects/topics_helper.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ module TopicsHelper
+ # To ensure a route will always generate, we need to encode `topic_name`.
+ # Otherwise, various pages will encounter `No route matches` error.
+ #
+ # This does mean some double encoding as Rails ActionDispatch also encodes
+ # segments but that is OK
+ #
+ # Also, controllers that use `params[:topic_name]` will now need to perform
+ # decode_www_form_component.
+ def topic_explore_projects_cleaned_path(topic_name:)
+ topic_name = URI.encode_www_form_component(topic_name) if Feature.enabled?(:explore_topics_cleaned_path)
+
+ topic_explore_projects_path(topic_name: topic_name)
+ end
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 1e87d2861d4..9415e7d4dc3 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -177,7 +177,11 @@ module ProjectsHelper
abilities = Array(search_tab_ability_map[tab])
- abilities.any? { |ability| can?(current_user, ability, @project) }
+ if @project.respond_to?(:each) # support multi-project select
+ @project.any? { |project| abilities.any? { |ability| can?(current_user, ability, project) } }
+ else
+ abilities.any? { |ability| can?(current_user, ability, @project) }
+ end
end
def can_change_visibility_level?(project, current_user)
@@ -421,8 +425,9 @@ module ProjectsHelper
packagesAvailable: ::Gitlab.config.packages.enabled,
packagesHelpPath: help_page_path('user/packages/index'),
currentSettings: project_permissions_settings(project),
- canDisableEmails: can_disable_emails?(project, current_user),
+ canAddCatalogResource: can_add_catalog_resource?(project),
canChangeVisibilityLevel: can_change_visibility_level?(project, current_user),
+ canDisableEmails: can_disable_emails?(project, current_user),
allowedVisibilityOptions: project_allowed_visibility_levels(project),
visibilityHelpPath: help_page_path('user/public_access'),
registryAvailable: Gitlab.config.registry.enabled,
@@ -615,7 +620,8 @@ module ProjectsHelper
commits: :read_code,
merge_requests: :read_merge_request,
notes: [:read_merge_request, :read_code, :read_issue, :read_snippet],
- members: :read_project_member
+ users: :read_project_member,
+ wiki_blobs: :read_wiki
)
end
@@ -737,7 +743,6 @@ module ProjectsHelper
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled,
emailsDisabled: project.emails_disabled?,
- metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
monitorAccessLevel: feature.monitor_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?,
@@ -747,7 +752,8 @@ module ProjectsHelper
environmentsAccessLevel: feature.environments_access_level,
featureFlagsAccessLevel: feature.feature_flags_access_level,
releasesAccessLevel: feature.releases_access_level,
- infrastructureAccessLevel: feature.infrastructure_access_level
+ infrastructureAccessLevel: feature.infrastructure_access_level,
+ modelExperimentsAccessLevel: feature.model_experiments_access_level
}
end
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
index fcd560dbe8c..4acba9b68d7 100644
--- a/app/helpers/registrations_helper.rb
+++ b/app/helpers/registrations_helper.rb
@@ -14,6 +14,11 @@ module RegistrationsHelper
def signup_box_template
'devise/shared/signup_box'
end
+
+ # overridden in EE
+ def register_omniauth_params(_local_assigns)
+ {}
+ end
end
RegistrationsHelper.prepend_mod_with('RegistrationsHelper')
diff --git a/app/helpers/resource_events/abuse_report_events_helper.rb b/app/helpers/resource_events/abuse_report_events_helper.rb
new file mode 100644
index 00000000000..8adbc891184
--- /dev/null
+++ b/app/helpers/resource_events/abuse_report_events_helper.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module ResourceEvents
+ module AbuseReportEventsHelper
+ def success_message_for_action(action)
+ case action
+ when 'ban_user'
+ s_('AbuseReportEvent|Successfully banned the user')
+ when 'block_user'
+ s_('AbuseReportEvent|Successfully blocked the user')
+ when 'delete_user'
+ s_('AbuseReportEvent|Successfully scheduled the user for deletion')
+ when 'close_report'
+ s_('AbuseReportEvent|Successfully closed the report')
+ when 'ban_user_and_close_report'
+ s_('AbuseReportEvent|Successfully banned the user and closed the report')
+ when 'block_user_and_close_report'
+ s_('AbuseReportEvent|Successfully blocked the user and closed the report')
+ when 'delete_user_and_close_report'
+ s_('AbuseReportEvent|Successfully scheduled the user for deletion and closed the report')
+ end
+ end
+ end
+end
diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb
index c79e8b50a1a..d39a972f3f3 100644
--- a/app/helpers/safe_format_helper.rb
+++ b/app/helpers/safe_format_helper.rb
@@ -1,23 +1,67 @@
# frozen_string_literal: true
module SafeFormatHelper
- # Returns a HTML-safe string where +format+ and +args+ are escaped via
- # `html_escape` if they are not marked as HTML-safe.
+ # Returns a HTML-safe String.
#
- # Argument +format+ must not be marked as HTML-safe via `.html_safe`.
+ # @param [String] format is escaped via `html_escape_once`
+ # @param [Array<Hash>] args are escaped via `html_escape` if they are not marked as HTML-safe
#
- # Example:
- # safe_format('Some %{open}bold%{close} text.', open: '<strong>'.html_safe, close: '</strong>'.html_safe)
- # # => 'Some <strong>bold</strong>'
+ # @example
# safe_format('See %{user_input}', user_input: '<b>bold</b>')
- # # => 'See &lt;b&gt;bold&lt;/b&gt;
+ # # => "See &lt;b&gt;bold&lt;/b&gt"
#
- def safe_format(format, **args)
- raise ArgumentError, 'Argument `format` must not be marked as html_safe!' if format.html_safe?
+ # safe_format('In &lt; hour & more')
+ # # => "In &lt; hour &amp; more"
+ #
+ # @example With +tag_pair+ support
+ # safe_format('Some %{open}bold%{close} text.', tag_pair(tag.strong, :open, :close))
+ # # => "Some <strong>bold</strong> text."
+ # safe_format('Some %{open}bold%{close} %{italicStart}text%{italicEnd}.',
+ # tag_pair(tag.strong, :open, :close),
+ # tag_pair(tag.i, :italicStart, :italicEnd))
+ # # => "Some <strong>bold</strong> <i>text</i>.
+ def safe_format(format, *args)
+ args = args.inject({}, &:merge)
- format(
- html_escape(format),
+ # Use `Kernel.format` to avoid conflicts with ViewComponent's `format`.
+ Kernel.format(
+ html_escape_once(format),
args.transform_values { |value| html_escape(value) }
).html_safe
end
+
+ # Returns a Hash containing a pair of +open+ and +close+ tag parts extracted
+ # from HTML-safe +tag+. The values are HTML-safe.
+ #
+ # Returns an empty Hash if +tag+ is not a valid paired tag (e.g. <p>foo</p>).
+ # an empty Hash is returned.
+ #
+ # @param [String] tag is a HTML-safe output from tag helper
+ # @param [Symbol,Object] open_name name of opening tag
+ # @param [Symbol,Object] close_name name of closing tag
+ # @raise [ArgumentError] if +tag+ is not HTML-safe
+ #
+ # @example
+ # tag_pair(tag.strong, :open, :close)
+ # # => { open: '<strong>', close: '</strong>' }
+ # tag_pair(link_to('', '/'), :open, :close)
+ # # => { open: '<a href="/">', close: '</a>' }
+ def tag_pair(html_tag, open_name, close_name)
+ raise ArgumentError, 'Argument `tag` must be `html_safe`!' unless html_tag.html_safe?
+ return {} unless html_tag.start_with?('<')
+
+ # end of opening tag: <p>foo</p>
+ # ^
+ open_index = html_tag.index('>')
+ # start of closing tag: <p>foo</p>
+ # ^^
+ close_index = html_tag.rindex('</')
+
+ return {} unless open_index && close_index
+
+ {
+ open_name => html_tag[0, open_index + 1],
+ close_name => html_tag[close_index, html_tag.size]
+ }
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 2187126272d..8fbbd18c9ae 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -14,12 +14,12 @@ module SearchHelper
:project_ids
].freeze
- def search_autocomplete_opts(term, filter: nil)
+ def search_autocomplete_opts(term, filter: nil, scope: nil)
return unless current_user
results = case filter&.to_sym
when :search
- resource_results(term)
+ resource_results(term, scope: scope)
when :generic
[
recent_items_autocomplete(term),
@@ -36,7 +36,10 @@ module SearchHelper
results.flatten { |item| item[:label] }
end
- def resource_results(term)
+ def resource_results(term, scope: nil)
+ return [] if term.length < Gitlab::Search::Params::MIN_TERM_LENGTH
+ return scope_specific_results(term, scope) if scope.present?
+
[
groups_autocomplete(term),
projects_autocomplete(term),
@@ -45,6 +48,19 @@ module SearchHelper
].flatten
end
+ def scope_specific_results(term, scope)
+ case scope&.to_sym
+ when :project
+ projects_autocomplete(term)
+ when :user
+ users_autocomplete(term)
+ when :issue
+ recent_issues_autocomplete(term)
+ else
+ []
+ end
+ end
+
def generic_results(term)
search_pattern = Regexp.new(Regexp.escape(term), "i")
@@ -307,7 +323,7 @@ module SearchHelper
# Autocomplete results for the current user's groups
# rubocop: disable CodeReuse/ActiveRecord
def groups_autocomplete(term, limit = 5)
- current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group|
+ current_user.authorized_groups.order_id_desc.search(term, use_minimum_char_limit: false).limit(limit).map do |group|
{
category: "Groups",
id: group.id,
@@ -341,7 +357,7 @@ module SearchHelper
# Autocomplete results for the current user's projects
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
- current_user.authorized_projects.order_id_desc.search(term, include_namespace: true)
+ current_user.authorized_projects.order_id_desc.search(term, include_namespace: true, use_minimum_char_limit: false)
.sorted_by_stars_desc.non_archived.limit(limit).map do |p|
{
category: "Projects",
@@ -357,10 +373,17 @@ module SearchHelper
def users_autocomplete(term, limit = 5)
return [] unless current_user && Ability.allowed?(current_user, :read_users_list)
- SearchService
- .new(current_user, { scope: 'users', per_page: limit, search: term })
- .search_objects
- .map do |user|
+ users = if Feature.enabled?(:autocomplete_users_use_search_service)
+ ::SearchService
+ .new(current_user, { scope: 'users', per_page: limit, search: term })
+ .search_objects
+ else
+ is_current_user_admin = current_user.can_admin_all_resources?
+ scope = is_current_user_admin ? User.all : User.without_forbidden_states
+ scope.search(term, with_private_emails: is_current_user_admin, use_minimum_char_limit: false).limit(limit)
+ end
+
+ users.map do |user|
{
category: "Users",
id: user.id,
@@ -448,38 +471,60 @@ module SearchHelper
result
end
- def code_tab_condition
+ def show_code_search_tab?
return true if project_search_tabs?(:blobs)
@project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_code_tab)
end
- def wiki_tab_condition
- return true if project_search_tabs?(:wiki)
+ def show_wiki_search_tab?
+ return true if project_search_tabs?(:wiki_blobs)
@project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_wiki_tab)
end
- def commits_tab_condition
+ def show_commits_search_tab?
return true if project_search_tabs?(:commits)
@project.nil? && search_service.show_elasticsearch_tabs? && feature_flag_tab_enabled?(:global_search_commits_tab)
end
+ def show_issues_search_tab?
+ return true if project_search_tabs?(:issues)
+
+ @project.nil? && feature_flag_tab_enabled?(:global_search_issues_tab)
+ end
+
+ def show_merge_requests_search_tab?
+ return true if project_search_tabs?(:merge_requests)
+
+ @project.nil? && feature_flag_tab_enabled?(:global_search_merge_requests_tab)
+ end
+
+ def show_comments_search_tab?
+ return true if project_search_tabs?(:notes)
+
+ @project.nil? && search_service.show_elasticsearch_tabs?
+ end
+
+ def show_snippets_search_tab?
+ search_service.show_snippets? && @project.nil? && feature_flag_tab_enabled?(:global_search_snippet_titles_tab)
+ end
+
# search page scope navigation
def search_navigation
{
projects: { sort: 1, label: _("Projects"), data: { qa_selector: 'projects_tab' }, condition: @project.nil? },
- blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: code_tab_condition },
+ blobs: { sort: 2, label: _("Code"), data: { qa_selector: 'code_tab' }, condition: show_code_search_tab? },
# sort: 3 is reserved for EE items
- issues: { sort: 4, label: _("Issues"), condition: project_search_tabs?(:issues) || feature_flag_tab_enabled?(:global_search_issues_tab) },
- merge_requests: { sort: 5, label: _("Merge requests"), condition: project_search_tabs?(:merge_requests) || feature_flag_tab_enabled?(:global_search_merge_requests_tab) },
- wiki_blobs: { sort: 6, label: _("Wiki"), condition: wiki_tab_condition },
- commits: { sort: 7, label: _("Commits"), condition: commits_tab_condition },
- notes: { sort: 8, label: _("Comments"), condition: project_search_tabs?(:notes) || search_service.show_elasticsearch_tabs? },
+ issues: { sort: 4, label: _("Issues"), condition: show_issues_search_tab? },
+ merge_requests: { sort: 5, label: _("Merge requests"), condition: show_merge_requests_search_tab? },
+ wiki_blobs: { sort: 6, label: _("Wiki"), condition: show_wiki_search_tab? },
+ commits: { sort: 7, label: _("Commits"), condition: show_commits_search_tab? },
+ notes: { sort: 8, label: _("Comments"), condition: show_comments_search_tab? },
milestones: { sort: 9, label: _("Milestones"), condition: project_search_tabs?(:milestones) || @project.nil? },
users: { sort: 10, label: _("Users"), condition: show_user_search_tab? },
- snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: search_service.show_snippets? && @project.nil? }
+ snippet_titles: { sort: 11, label: _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }, condition: show_snippets_search_tab? }
}
end
@@ -567,7 +612,7 @@ module SearchHelper
end
def show_user_search_tab?
- return project_search_tabs?(:members) if @project
+ return project_search_tabs?(:users) if @project
return false unless can?(current_user, :read_users_list)
return true if @group
@@ -608,7 +653,14 @@ module SearchHelper
def sanitized_search_params
sanitized_params = params.dup
- sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential]) if sanitized_params.key?(:confidential)
+
+ if sanitized_params.key?(:confidential)
+ sanitized_params[:confidential] = Gitlab::Utils.to_boolean(sanitized_params[:confidential])
+ end
+
+ if sanitized_params.key?(:include_archived)
+ sanitized_params[:include_archived] = Gitlab::Utils.to_boolean(sanitized_params[:include_archived])
+ end
sanitized_params
end
diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb
index 13d6851f3cd..6100093b7c2 100644
--- a/app/helpers/ssh_keys_helper.rb
+++ b/app/helpers/ssh_keys_helper.rb
@@ -52,6 +52,6 @@ module SshKeysHelper
quoted_allowed_algorithms = allowed_algorithms.map { |name| "'#{name}'" }
- Gitlab::Utils.to_exclusive_sentence(quoted_allowed_algorithms)
+ Gitlab::Sentence.to_exclusive_sentence(quoted_allowed_algorithms)
end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 0aeea323ddb..84512453b7c 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -157,17 +157,15 @@ module TreeHelper
}
end
- def fork_modal_options(project, ref, path, blob)
+ def fork_modal_options(project, blob)
if show_edit_button?({ blob: blob })
- fork_path = fork_and_edit_path(project, ref, path)
fork_modal_id = "modal-confirm-fork-edit"
elsif show_web_ide_button?
- fork_path = ide_fork_and_edit_path(project, ref, path)
fork_modal_id = "modal-confirm-fork-webide"
end
{
- fork_path: fork_path,
+ fork_path: new_namespace_project_fork_path(project_id: project.path, namespace_id: project.namespace.full_path),
fork_modal_id: fork_modal_id
}
end
diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb
index af3ac495164..0f4cbd6642b 100644
--- a/app/helpers/users/callouts_helper.rb
+++ b/app/helpers/users/callouts_helper.rb
@@ -16,6 +16,7 @@ module Users
WEB_HOOK_DISABLED = 'web_hook_disabled'
ULTIMATE_FEATURE_REMOVAL_BANNER = 'ultimate_feature_removal_banner'
BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout'
+ NEW_NAVIGATION_CALLOUT = 'new_navigation_callout'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -79,6 +80,18 @@ module Users
!user_dismissed?(BRANCH_RULES_INFO_CALLOUT)
end
+ def show_new_navigation_callout?
+ show_super_sidebar? &&
+ !user_dismissed?(NEW_NAVIGATION_CALLOUT) &&
+ # GitLab.com users created after the feature flag's full rollout (June 2nd 2023) don't need to see the callout.
+ # Remove the gitlab_com_user_created_after_new_nav_rollout? method when the callout isn't needed anymore.
+ !gitlab_com_user_created_after_new_nav_rollout?
+ end
+
+ def gitlab_com_user_created_after_new_nav_rollout?
+ Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2)
+ end
+
def ultimate_feature_removal_banner_dismissed?(project)
return false unless project
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 60230d58e30..c8002c437a9 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -184,14 +184,28 @@ module UsersHelper
def user_profile_tabs_app_data(user)
{
- followees: user.followees.count,
- followers: user.followers.count,
+ followees_count: user.followees.count,
+ followers_count: user.followers.count,
user_calendar_path: user_calendar_path(user, :json),
+ user_activity_path: user_activity_path(user, :json),
utc_offset: local_timezone_instance(user.timezone).now.utc_offset,
- user_id: user.id
+ user_id: user.id,
+ snippets_empty_state: image_path('illustrations/empty-state/empty-snippets-md.svg')
}
end
+ def moderation_status(user)
+ return unless user.present?
+
+ if user.banned?
+ _('Banned')
+ elsif user.blocked?
+ _('Blocked')
+ else
+ _('Active')
+ end
+ end
+
private
def admin_users_paths
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index c627f4633e4..f609c9318da 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -79,8 +79,7 @@ module Emails
options = {
from: email_sender,
to: @service_desk_setting.custom_email_address_for_verification,
- subject: subject,
- content_type: "text/plain; charset=UTF-8"
+ subject: subject
}
# Outgoing emails from GitLab usually have this set to true.
# Service Desk email ingestion ignores auto generated emails.
@@ -176,6 +175,11 @@ module Emails
.gsub(/%\{\s*SYSTEM_FOOTER\s*\}/, text_footer_message.to_s)
.gsub(/%\{\s*UNSUBSCRIBE_URL\s*\}/, unsubscribe_sent_notification_url(@sent_notification))
.gsub(/%\{\s*ADDITIONAL_TEXT\s*\}/, service_desk_email_additional_text.to_s)
+ .gsub(/%\{\s*ISSUE_URL\s*\}/, full_issue_url)
+ end
+
+ def full_issue_url
+ issue_url(@issue)
end
def issue_id
diff --git a/app/models/abuse/event.rb b/app/models/abuse/event.rb
new file mode 100644
index 00000000000..5700c1c73e6
--- /dev/null
+++ b/app/models/abuse/event.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Abuse
+ class Event < ApplicationRecord
+ self.table_name = 'abuse_events'
+
+ validates :category, presence: true
+ validates :source, presence: true
+ validates :user, presence: true, on: :create
+ validates :metadata, json_schema: { filename: 'abuse_event_metadata' }, allow_blank: true
+
+ belongs_to :user, inverse_of: :abuse_events
+ belongs_to :abuse_report, inverse_of: :abuse_events
+
+ enum category: Enums::Abuse::Category.categories
+ enum source: Enums::Abuse::Source.sources
+ end
+end
diff --git a/app/models/abuse/trust_score.rb b/app/models/abuse/trust_score.rb
index 9ad7c9b14b1..b7ed504a0ba 100644
--- a/app/models/abuse/trust_score.rb
+++ b/app/models/abuse/trust_score.rb
@@ -3,6 +3,7 @@
module Abuse
class TrustScore < ApplicationRecord
MAX_EVENTS = 100
+ SPAMCHECK_HAM_THRESHOLD = 0.5
self.table_name = 'abuse_trust_scores'
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 55b1aff51da..1d2eee82827 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -12,13 +12,17 @@ class AbuseReport < ApplicationRecord
cache_markdown_field :message, pipeline: :single_line
- belongs_to :reporter, class_name: 'User'
- belongs_to :user
+ belongs_to :reporter, class_name: 'User', inverse_of: :reported_abuse_reports
+ belongs_to :user, inverse_of: :abuse_reports
+ belongs_to :resolved_by, class_name: 'User', inverse_of: :resolved_abuse_reports
+ belongs_to :assignee, class_name: 'User', inverse_of: :assigned_abuse_reports
has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report
- validates :reporter, presence: true
- validates :user, presence: true
+ has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report
+
+ validates :reporter, presence: true, on: :create
+ validates :user, presence: true, on: :create
validates :message, presence: true
validates :category, presence: true
validates :user_id,
@@ -27,7 +31,7 @@ class AbuseReport < ApplicationRecord
message: ->(object, data) do
_('You have already reported this user')
end
- }
+ }, on: :create
validates :reported_from_url,
allow_blank: true,
@@ -45,6 +49,9 @@ class AbuseReport < ApplicationRecord
message: N_("exceeds the limit of %{count} links")
}
+ validates :mitigation_steps, length: { maximum: 1000 }, allow_blank: true
+ validates :evidence, json_schema: { filename: 'abuse_report_evidence' }, allow_blank: true
+
before_validation :filter_empty_strings_from_links_to_spam
validate :links_to_spam_contains_valid_urls
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index 906855d6dfc..d5162865a79 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -3,8 +3,8 @@
module AlertManagement
class HttpIntegration < ApplicationRecord
include ::Gitlab::Routing
+
LEGACY_IDENTIFIER = 'legacy'
- DEFAULT_NAME_SLUG = 'http-endpoint'
belongs_to :project, inverse_of: :alert_management_http_integrations
@@ -19,6 +19,7 @@ module AlertManagement
validates :active, inclusion: { in: [true, false] }
validates :token, presence: true, format: { with: /\A\h{32}\z/ }
validates :name, presence: true, length: { maximum: 255 }
+ validates :type_identifier, presence: true
validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ }
validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' }
@@ -29,15 +30,30 @@ module AlertManagement
before_validation :ensure_payload_example_not_nil
scope :for_endpoint_identifier, ->(endpoint_identifier) { where(endpoint_identifier: endpoint_identifier) }
+ scope :for_type, ->(type) { where(type_identifier: type) }
+ scope :for_project, ->(project_ids) { where(project: project_ids) }
scope :active, -> { where(active: true) }
- scope :ordered_by_id, -> { order(:id) }
+ scope :legacy, -> { for_endpoint_identifier(LEGACY_IDENTIFIER) }
+ scope :ordered_by_type_and_id, -> { order(:type_identifier, :id) }
+
+ enum type_identifier: {
+ http: 0,
+ prometheus: 1
+ }
def url
- return project_alerts_notify_url(project, format: :json) if legacy?
+ if legacy?
+ return project_alerts_notify_url(project, format: :json) if http?
+ return notify_project_prometheus_alerts_url(project, format: :json) if prometheus?
+ end
project_alert_http_integration_url(project, name_slug, endpoint_identifier, format: :json)
end
+ def legacy?
+ endpoint_identifier == LEGACY_IDENTIFIER
+ end
+
private
def self.generate_token
@@ -45,11 +61,7 @@ module AlertManagement
end
def name_slug
- (name && Gitlab::Utils.slugify(name)) || DEFAULT_NAME_SLUG
- end
-
- def legacy?
- endpoint_identifier == LEGACY_IDENTIFIER
+ (name && Gitlab::Utils.slugify(name)) || "#{type_identifier}-endpoint"
end
# Blank token assignment triggers token reset
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
index fa165ae9600..0f8e184933e 100644
--- a/app/models/analytics/cycle_analytics/aggregation.rb
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -17,11 +17,13 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
end
def consistency_check_cursor_for(model)
+ return {} if self["last_consistency_check_#{model.issuable_model.table_name}_issuable_id"].nil?
+
{
:start_event_timestamp => self["last_consistency_check_#{model.issuable_model.table_name}_start_event_timestamp"],
:end_event_timestamp => self["last_consistency_check_#{model.issuable_model.table_name}_end_event_timestamp"],
model.issuable_id_column => self["last_consistency_check_#{model.issuable_model.table_name}_issuable_id"]
- }.compact
+ }
end
def refresh_last_run(mode)
diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb
index 59c68393d74..31e06075bcb 100644
--- a/app/models/analytics/cycle_analytics/value_stream.rb
+++ b/app/models/analytics/cycle_analytics/value_stream.rb
@@ -21,6 +21,7 @@ module Analytics
scope :preload_associated_models, -> {
includes(:namespace, stages: [:namespace, :end_event_label, :start_event_label])
}
+ scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) }
after_save :ensure_aggregation_record_presence
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index d2ca88aae0e..a71b47e88d8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -13,8 +13,32 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18'
ignore_column :send_user_confirmation_email, remove_with: '15.8', remove_after: '2022-12-18'
ignore_column :web_ide_clientside_preview_enabled, remove_with: '15.11', remove_after: '2023-04-22'
- ignore_column :clickhouse_connection_string, remove_with: '16.1', remove_after: '2023-05-22'
ignore_columns %i[instance_administration_project_id instance_administrators_group_id], remove_with: '16.2', remove_after: '2023-06-22'
+ ignore_columns %i[
+ encrypted_tofa_access_token_expires_in
+ encrypted_tofa_access_token_expires_in_iv
+ encrypted_tofa_client_library_args
+ encrypted_tofa_client_library_args_iv
+ encrypted_tofa_client_library_class
+ encrypted_tofa_client_library_class_iv
+ encrypted_tofa_client_library_create_credentials_method
+ encrypted_tofa_client_library_create_credentials_method_iv
+ encrypted_tofa_client_library_fetch_access_token_method
+ encrypted_tofa_client_library_fetch_access_token_method_iv
+ encrypted_tofa_credentials
+ encrypted_tofa_credentials_iv
+ encrypted_tofa_host
+ encrypted_tofa_host_iv
+ encrypted_tofa_request_json_keys
+ encrypted_tofa_request_json_keys_iv
+ encrypted_tofa_request_payload
+ encrypted_tofa_request_payload_iv
+ encrypted_tofa_response_json_keys
+ encrypted_tofa_response_json_keys_iv
+ encrypted_tofa_url
+ encrypted_tofa_url_iv
+ vertex_project
+ ], remove_with: '16.2', remove_after: '2023-06-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -31,6 +55,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
archive_builds_in_seconds: 'Archive job value'
}.freeze
+ # matches the size set in the database constraint
+ DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE = 1.kilobyte
+
enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true
enum email_confirmation_setting: { off: 0, soft: 1, hard: 2 }, _prefix: true
@@ -86,6 +113,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
attribute :id, default: 1
attribute :repository_storages_weighted, default: -> { {} }
attribute :kroki_formats, default: -> { {} }
+ attribute :default_branch_protection_defaults, default: -> { {} }
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
@@ -93,6 +121,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
chronic_duration_attr :group_runner_token_expiration_interval_human_readable, :group_runner_token_expiration_interval
chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval
+ validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' }
+ validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } }
+
validates :grafana_url,
system_hook_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({
blocked_message: "is blocked: %{exception_message}. #{GRAFANA_URL_ERROR_MESSAGE}"
@@ -187,6 +218,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :sourcegraph_url, presence: true, if: :sourcegraph_enabled
+ validates :diagramsnet_url,
+ presence: true,
+ addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ enforce_sanitization: true }),
+ if: :diagramsnet_enabled
+
validates :gitpod_url,
presence: true,
addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS.merge({ enforce_sanitization: true }),
@@ -379,6 +415,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 }
validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes }
+ validates :wiki_asciidoc_allow_uri_includes, inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true
validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true
@@ -390,6 +427,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :container_registry_delete_tags_service_timeout,
:container_registry_cleanup_tags_service_max_list_size,
+ :container_registry_data_repair_detail_worker_max_concurrency,
:container_registry_expiration_policies_worker_capacity,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
@@ -590,6 +628,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :search_rate_limit
validates :search_rate_limit_unauthenticated
validates :projects_api_rate_limit_unauthenticated
+ validates :gitlab_shell_operation_limit
end
validates :notes_create_limit_allowlist,
@@ -668,6 +707,17 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
validates :database_apdex_settings, json_schema: { filename: 'application_setting_database_apdex_settings' }, allow_nil: true
+ validates :namespace_aggregation_schedule_lease_duration_in_seconds,
+ numericality: { only_integer: true, greater_than: 0 }
+
+ validates :instance_level_code_suggestions_enabled,
+ allow_nil: false,
+ inclusion: { in: [true, false], message: N_('must be a boolean value') }
+
+ validates :ai_access_token,
+ presence: { message: N_("is required to enable Code Suggestions") },
+ if: :instance_level_code_suggestions_enabled
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -713,18 +763,8 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
attr_encrypted :product_analytics_configurator_connection_string, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :openai_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
attr_encrypted :anthropic_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- # TOFA API integration settngs
- attr_encrypted :tofa_client_library_args, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_client_library_class, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_client_library_create_credentials_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_client_library_fetch_access_token_method, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_host, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_request_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_request_payload, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_response_json_keys, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_url, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
- attr_encrypted :tofa_access_token_expires_in, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :ai_access_token, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
+ attr_encrypted :vertex_ai_credentials, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false)
validates :disable_feed_token,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
@@ -752,7 +792,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
before_validation :normalize_default_branch_name
- before_validation :remove_old_import_sources
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -796,10 +835,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
users_count >= INSTANCE_REVIEW_MIN_USERS
end
- def remove_old_import_sources
- self.import_sources -= %w[phabricator gitlab] if self.import_sources
- end
-
Recursion = Class.new(RuntimeError)
def self.create_from_defaults
@@ -911,4 +946,5 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord
end
end
+ApplicationSetting.prepend(ApplicationSettingMaskedAttrs)
ApplicationSetting.prepend_mod_with('ApplicationSetting')
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 845d402f550..81e816a5b7c 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -37,6 +37,7 @@ module ApplicationSettingImplementation
{
admin_mode: false,
after_sign_up_text: nil,
+ ai_access_token: nil,
akismet_enabled: false,
akismet_api_key: nil,
allow_local_requests_from_system_hooks: true,
@@ -104,6 +105,7 @@ module ApplicationSettingImplementation
housekeeping_gc_period: 200,
housekeeping_incremental_repack_period: 10,
import_sources: Settings.gitlab['import_sources'],
+ instance_level_code_suggestions_enabled: false,
invisible_captcha_enabled: false,
issues_create_limit: 300,
jira_connect_application_key: nil,
@@ -132,6 +134,8 @@ module ApplicationSettingImplementation
personal_access_token_prefix: 'glpat-',
plantuml_enabled: false,
plantuml_url: nil,
+ diagramsnet_enabled: true,
+ diagramsnet_url: 'https://embed.diagrams.net',
polling_interval_multiplier: 1,
productivity_analytics_start_date: Time.current,
project_download_export_limit: 1,
@@ -223,6 +227,7 @@ module ApplicationSettingImplementation
user_show_add_ssh_key_message: true,
valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES,
wiki_page_max_content_bytes: 50.megabytes,
+ wiki_asciidoc_allow_uri_includes: false,
package_registry_cleanup_policies_worker_capacity: 2,
container_registry_delete_tags_service_timeout: 250,
container_registry_expiration_policies_worker_capacity: 4,
@@ -253,7 +258,9 @@ module ApplicationSettingImplementation
user_defaults_to_private_profile: false,
projects_api_rate_limit_unauthenticated: 400,
gitlab_dedicated_instance: false,
- ci_max_includes: 150
+ ci_max_includes: 150,
+ allow_account_deletion: true,
+ gitlab_shell_operation_limit: 600
}.tap do |hsh|
hsh.merge!(non_production_defaults) unless Rails.env.production?
end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 163e741d990..9370982be47 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -100,6 +100,40 @@ class AuditEvent < ApplicationRecord
super || details[:target_details]
end
+ def self.by_group(group)
+ group_id = group.id
+
+ # Bring entity_type and entity_id from projects and group into one query
+ scope1 = Group.find(group_id).all_projects.select("'Project' as entity_type", 'id AS entity_id')
+ scope2 = Project.from("(VALUES ('Group', #{group_id})) as projects(entity_type, entity_id)").select('entity_type',
+ 'entity_id')
+ array_scope = Project.from_union([scope1, scope2], remove_duplicates: false).select(:entity_type, :entity_id)
+
+ # order by created_at (id is the tie breaker)
+ scope = AuditEvent.order(:created_at, :id)
+
+ array_mapping_scope = ->(entity_type_expression, entity_id_expression) do
+ AuditEvent.where(AuditEvent.arel_table[:entity_id].eq(entity_id_expression))
+ .where(AuditEvent.arel_table[:entity_type].eq(entity_type_expression))
+ end
+
+ finder_query = ->(created_at_expression, id_expression) do
+ # we need to add created_at filter as well because that's the partitioning key
+ AuditEvent.where(
+ AuditEvent.arel_table[:id].eq(id_expression)
+ ).where(
+ AuditEvent.arel_table[:created_at].eq(created_at_expression)
+ )
+ end
+
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new(
+ scope: scope,
+ array_scope: array_scope,
+ array_mapping_scope: array_mapping_scope,
+ finder_query: finder_query
+ ).execute
+ end
+
private
def sanitize_message
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 20d7c230aa2..bb8c9345573 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -33,6 +33,7 @@ class Blob < SimpleDelegator
BlobViewer::Notebook,
BlobViewer::SVG,
BlobViewer::OpenApi,
+ BlobViewer::GeoJson,
BlobViewer::Image,
BlobViewer::Sketch,
@@ -54,7 +55,6 @@ class Blob < SimpleDelegator
BlobViewer::License,
BlobViewer::Contributing,
BlobViewer::Changelog,
- BlobViewer::MetricsDashboardYml,
BlobViewer::CargoToml,
BlobViewer::Cartfile,
@@ -72,6 +72,7 @@ class Blob < SimpleDelegator
].freeze
attr_reader :container
+ attr_accessor :ref_type
delegate :repository, to: :container, allow_nil: true
delegate :project, to: :repository, allow_nil: true
diff --git a/app/models/blob_viewer/geo_json.rb b/app/models/blob_viewer/geo_json.rb
new file mode 100644
index 00000000000..01dcf7707eb
--- /dev/null
+++ b/app/models/blob_viewer/geo_json.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class GeoJson < Base
+ include Rich
+ include ClientSide
+
+ self.binary = false
+ self.extensions = %w[geojson]
+ self.partial_name = 'geo_json'
+ end
+end
diff --git a/app/models/blob_viewer/metrics_dashboard_yml.rb b/app/models/blob_viewer/metrics_dashboard_yml.rb
deleted file mode 100644
index b63f3022198..00000000000
--- a/app/models/blob_viewer/metrics_dashboard_yml.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-module BlobViewer
- class MetricsDashboardYml < Base
- include ServerSide
- include Gitlab::Utils::StrongMemoize
- include Auxiliary
-
- self.partial_name = 'metrics_dashboard_yml'
- self.loading_partial_name = 'metrics_dashboard_yml_loading'
- self.file_types = %i(metrics_dashboard)
- self.binary = false
-
- def self.can_render?(blob, verify_binary: true)
- super && !Feature.enabled?(:remove_monitor_metrics)
- end
-
- def valid?
- errors.blank?
- end
-
- def errors
- strong_memoize(:errors) do
- prepare!
- parse_blob_data
- end
- end
-
- private
-
- def parse_blob_data
- old_metrics_dashboard_validation
- end
-
- def old_metrics_dashboard_validation
- yaml = ::Gitlab::Config::Loader::Yaml.new(blob.data).load_raw!
- ::PerformanceMonitoring::PrometheusDashboard.from_json(yaml)
- []
- rescue Gitlab::Config::Loader::FormatError => e
- ["YAML syntax: #{e.message}"]
- rescue ActiveModel::ValidationError => e
- e.model.errors.messages.map { |messages| messages.join(': ') }
- end
- end
-end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 733018160cd..bf25ea7367c 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -22,6 +22,7 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord
validates :ends_at, presence: true
validates :broadcast_type, presence: true
validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS }
+ validates :show_in_cli, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') }
validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true
@@ -29,6 +30,8 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord
attribute :color, default: '#E75E40'
attribute :font, default: '#FFFFFF'
+ scope :current_and_future_messages, -> { where('ends_at > :now', now: Time.current).order_id_asc }
+
CACHE_KEY = 'broadcast_message_current_json'
BANNER_CACHE_KEY = 'broadcast_message_current_banner_json'
NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json'
@@ -60,6 +63,10 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord
end
end
+ def current_show_in_cli_banner_messages
+ current_banner_messages.select(&:show_in_cli?)
+ end
+
def current_notification_messages(current_path: nil, user_access_level: nil)
fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do
current_and_future_messages.notification
@@ -72,13 +79,9 @@ class BroadcastMessage < MainClusterwide::ApplicationRecord
end
end
- def current_and_future_messages
- where('ends_at > :now', now: Time.current).order_id_asc
- end
-
def cache
::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do
- Gitlab::JsonCache.new
+ Gitlab::Cache::JsonCaches::JsonKeyed.new
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 61585de4ff7..bb1bfe8c889 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -48,6 +48,7 @@ module Ci
# Details: https://gitlab.com/gitlab-org/gitlab/-/issues/24644#note_689472685
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id, inverse_of: :job
+ has_many :job_annotations, class_name: 'Ci::JobAnnotation', foreign_key: :job_id, inverse_of: :job
has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :build
has_many :pages_deployments, foreign_key: :ci_build_id, inverse_of: :ci_build
@@ -259,10 +260,6 @@ module Ci
!build.any_unmet_prerequisites? # If false is returned, it stops the transition
end
- before_transition on: :enqueue do |build|
- !build.waiting_for_deployment_approval? # If false is returned, it stops the transition
- end
-
before_transition any => [:pending] do |build|
build.ensure_token
true
@@ -428,11 +425,7 @@ module Ci
end
def playable?
- action? && !archived? && (manual? || scheduled? || retryable?) && !waiting_for_deployment_approval?
- end
-
- def waiting_for_deployment_approval?
- manual? && deployment_job? && deployment&.blocked?
+ action? && !archived? && (manual? || scheduled? || retryable?)
end
def outdated_deployment?
@@ -598,14 +591,6 @@ module Ci
.append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
.append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true)
.append(key: 'CI_JOB_STARTED_AT', value: started_at&.iso8601)
-
- if Feature.disabled?(:ci_remove_legacy_predefined_variables, project)
- variables
- .append(key: 'CI_BUILD_ID', value: id.to_s)
- .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
- end
-
- variables
.append(key: 'CI_REGISTRY_USER', value: ::Gitlab::Auth::CI_JOB_USER)
.append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
.append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
@@ -658,9 +643,8 @@ module Ci
def apple_app_store_variables
return [] unless apple_app_store_integration.try(:activated?)
- return [] unless pipeline.protected_ref?
- Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables)
+ Gitlab::Ci::Variables::Collection.new(apple_app_store_integration.ci_variables(protected_ref: pipeline.protected_ref?))
end
def google_play_variables
@@ -1274,7 +1258,7 @@ module Ci
def id_tokens_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
id_tokens.each do |var_name, token_data|
- token = Gitlab::Ci::JwtV2.for_build(self, aud: token_data['aud'])
+ token = Gitlab::Ci::JwtV2.for_build(self, aud: expanded_id_token_aud(token_data['aud']))
variables.append(key: var_name, value: token, public: false, masked: true)
end
@@ -1283,6 +1267,19 @@ module Ci
end
end
+ def expanded_id_token_aud(aud)
+ return unless aud
+
+ strong_memoize_with(:expanded_id_token_aud, aud) do
+ # `aud` can be a string or an array of strings.
+ if aud.is_a?(Array)
+ aud.map { |x| ExpandVariables.expand(x, -> { scoped_variables.sort_and_expand_all }) }
+ else
+ ExpandVariables.expand(aud, -> { scoped_variables.sort_and_expand_all })
+ end
+ end
+ end
+
def cache_for_online_runners(&block)
Rails.cache.fetch(
['has-online-runners', id],
diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb
index b9e777f27a0..1cb030c67c3 100644
--- a/app/models/ci/catalog/listing.rb
+++ b/app/models/ci/catalog/listing.rb
@@ -14,16 +14,25 @@ module Ci
@current_user = current_user
end
- def resources
- Ci::Catalog::Resource
- .joins(:project).includes(:project)
- .merge(projects_in_namespace_visible_to_user)
+ def resources(sort: nil)
+ case sort.to_s
+ when 'name_desc' then all_resources.order_by_name_desc
+ when 'name_asc' then all_resources.order_by_name_asc
+ else
+ all_resources.order_by_created_at_desc
+ end
end
private
attr_reader :namespace, :current_user
+ def all_resources
+ Ci::Catalog::Resource
+ .joins(:project).includes(:project)
+ .merge(projects_in_namespace_visible_to_user)
+ end
+
def projects_in_namespace_visible_to_user
Project
.in_namespace(namespace.self_and_descendant_ids)
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index bb4584aacae..77cfe91ddd6 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -13,16 +13,21 @@ module Ci
belongs_to :project
scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
+ scope :order_by_created_at_desc, -> { reorder(created_at: :desc) }
+ scope :order_by_name_desc, -> { joins(:project).merge(Project.sorted_by_name_desc) }
+ scope :order_by_name_asc, -> { joins(:project).merge(Project.sorted_by_name_asc) }
- delegate :avatar_path, :description, :name, to: :project
+ delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project
def versions
project.releases.order_released_desc
end
def latest_version
- versions.first
+ project.releases.latest
end
end
end
end
+
+Ci::Catalog::Resource.prepend_mod_with('Ci::Catalog::Resource')
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index f04f0d27e51..5522a01758f 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -23,6 +23,19 @@ module Ci
scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
scope :for_groups, ->(group_ids) { where(group_id: group_ids) }
+ scope :for_environment_scope_like, -> (query) do
+ top_level = 'LOWER(ci_group_variables.environment_scope) LIKE LOWER(?) || \'%\''
+ search_like = "%#{sanitize_sql_like(query)}%"
+
+ where(top_level, search_like)
+ end
+
+ scope :environment_scope_names, -> do
+ group(:environment_scope)
+ .order(:environment_scope)
+ .pluck(:environment_scope)
+ end
+
self.limit_name = 'group_ci_variables'
self.limit_scope = :group
diff --git a/app/models/ci/job_annotation.rb b/app/models/ci/job_annotation.rb
new file mode 100644
index 00000000000..a8bef02cc42
--- /dev/null
+++ b/app/models/ci/job_annotation.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class JobAnnotation < Ci::ApplicationRecord
+ include Ci::Partitionable
+
+ self.table_name = :p_ci_job_annotations
+ self.primary_key = :id
+
+ belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_annotations
+
+ partitionable scope: :job, partitioned: true
+
+ validates :data, json_schema: { filename: 'ci_job_annotation_data' }
+ validates :name, presence: true,
+ length: { maximum: 255 },
+ uniqueness: { scope: [:job_id, :partition_id] }
+ end
+end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 766155c6a99..5cd7988837e 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -372,11 +372,11 @@ module Ci
file_stored_after_transaction_hooks
end
- # method overriden in EE
+ # method overridden in EE
def file_stored_after_transaction_hooks
end
- # method overriden in EE
+ # method overridden in EE
def file_stored_in_transaction_hooks
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index babea831d85..6f2939583e0 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -366,7 +366,6 @@ module Ci
project = pipeline&.project
next unless project
- next unless Feature.enabled?(:pipeline_trigger_merge_status, project)
pipeline.run_after_commit do
next if pipeline.child?
@@ -384,6 +383,10 @@ module Ci
scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) }
scope :ci_branch_sources, -> { where(source: Enums::Ci::Pipeline.ci_branch_sources.values) }
scope :ci_and_parent_sources, -> { where(source: Enums::Ci::Pipeline.ci_and_parent_sources.values) }
+ scope :ci_and_security_orchestration_sources, -> do
+ where(source: Enums::Ci::Pipeline.ci_and_security_orchestration_sources.values)
+ end
+
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
scope :where_not_sha, -> (sha) { where.not(sha: sha) }
@@ -675,32 +678,6 @@ module Ci
canceled? && auto_canceled_by_id?
end
- # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs
- # retries - # of times to retry if errors
- # cascade_to_children - if true cancels all related child pipelines for parent child pipelines
- # auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation
- # execute_async - if true cancel the children asyncronously
- def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true)
- Gitlab::AppJsonLogger.info(
- event: 'pipeline_cancel_running',
- pipeline_id: id,
- auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id,
- cascade_to_children: cascade_to_children,
- execute_async: execute_async,
- **Gitlab::ApplicationContext.current
- )
-
- update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id
-
- cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
-
- if cascade_to_children
- # cancel any bridges that could spin up new child pipelines
- cancel_jobs(bridges_in_self_and_project_descendants.cancelable, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)
- cancel_children(auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id, execute_async: execute_async)
- end
- end
-
# rubocop: disable CodeReuse/ServiceClass
def retry_failed(current_user)
Ci::RetryPipelineService.new(project, current_user)
@@ -1375,42 +1352,6 @@ module Ci
private
- def cancel_jobs(jobs, retries: 1, auto_canceled_by_pipeline_id: nil)
- retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |statuses|
- preloaded_relations = [:project, :pipeline, :deployment, :taggings]
-
- statuses.find_in_batches do |status_batch|
- relation = CommitStatus.where(id: status_batch)
- Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations)
-
- relation.each do |job|
- job.auto_canceled_by_id = auto_canceled_by_pipeline_id if auto_canceled_by_pipeline_id
- job.cancel
- end
- end
- end
- end
-
- # For parent child-pipelines only (not multi-project)
- def cancel_children(auto_canceled_by_pipeline_id: nil, execute_async: true)
- all_child_pipelines.each do |child_pipeline|
- if execute_async
- ::Ci::CancelPipelineWorker.perform_async(
- child_pipeline.id,
- auto_canceled_by_pipeline_id
- )
- else
- child_pipeline.cancel_running(
- # cascade_to_children is false because we iterate through children
- # we also cancel bridges prior to prevent more children
- cascade_to_children: false,
- execute_async: execute_async,
- auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id
- )
- end
- end
- end
-
def add_message(severity, content)
messages.build(severity: severity, content: content)
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 7727e94875b..6319163b0d7 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -100,7 +100,10 @@ module Ci
scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) }
scope :with_running_builds, -> do
- where('EXISTS(?)', ::Ci::Build.running.select(1).where('ci_builds.runner_id = ci_runners.id'))
+ where('EXISTS(?)',
+ ::Ci::Build.running.select(1)
+ .where("#{::Ci::Build.quoted_table_name}.runner_id = #{quoted_table_name}.id")
+ )
end
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index 5e273e0fd4b..af04db0a153 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -29,6 +29,7 @@ module Ci
scope :order_by_created_at, -> { order(created_at: :desc) }
scope :project_id_in, ->(ids) { where(project_id: ids) }
+ scope :with_files_stored_locally, -> { where(file_store: Ci::SecureFileUploader::Store::LOCAL) }
def checksum_algorithm
CHECKSUM_ALGORITHM
@@ -69,6 +70,10 @@ module Ci
end
end
+ def local?
+ file_store == ObjectStorage::Store::LOCAL
+ end
+
private
def assign_checksum
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 6980ec1c2d3..372fdfda1ea 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -3,6 +3,7 @@
module Clusters
class Agent < ApplicationRecord
include FromUnion
+ include Gitlab::Utils::StrongMemoize
self.table_name = 'cluster_agents'
@@ -29,6 +30,8 @@ module Clusters
has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent
+ has_many :environments, class_name: '::Environment', inverse_of: :cluster_agent, foreign_key: :cluster_agent_id
+
scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) }
scope :has_vulnerabilities, -> (value = true) { where(has_vulnerabilities: value) }
@@ -65,10 +68,8 @@ module Clusters
return false unless user
return false unless ::Feature.enabled?(:expose_authorized_cluster_agents, project)
- ::Project.from_union(
- all_ci_access_authorized_projects_for(user).limit(1),
- all_ci_access_authorized_namespaces_for(user).limit(1)
- ).exists?
+ all_ci_access_authorized_projects_for(user).exists? ||
+ all_ci_access_authorized_namespaces_for(user).exists?
end
def user_access_authorized_for?(user)
@@ -93,47 +94,35 @@ module Clusters
def all_ci_access_authorized_projects_for(user)
::Project.joins(:ci_access_project_authorizations)
.joins(:project_authorizations)
+ .joins(:namespace)
.where(agent_project_authorizations: { agent_id: id })
.where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
+ .where('namespaces.traversal_ids @> ARRAY[?]', root_namespace.id)
end
def all_ci_access_authorized_namespaces_for(user)
- ::Project.with(root_namespace_cte.to_arel)
- .with(all_ci_access_authorized_namespaces_cte.to_arel)
+ ::Project.with(all_ci_access_authorized_namespaces_cte.to_arel)
.joins('INNER JOIN all_authorized_namespaces ON all_authorized_namespaces.id = projects.namespace_id')
.joins(:project_authorizations)
.where(project_authorizations: { user_id: user.id, access_level: Gitlab::Access::DEVELOPER.. })
end
- def root_namespace_cte
- Gitlab::SQL::CTE.new(:root_namespace, root_namespace.to_sql)
- end
-
def all_ci_access_authorized_namespaces_cte
Gitlab::SQL::CTE.new(:all_authorized_namespaces, all_ci_access_authorized_namespaces.to_sql)
end
def all_ci_access_authorized_namespaces
Namespace.select("traversal_ids[array_length(traversal_ids, 1)] AS id")
- .joins("INNER JOIN root_namespace ON " \
- "namespaces.traversal_ids @> ARRAY[root_namespace.root_id]")
.joins("INNER JOIN agent_group_authorizations ON " \
"namespaces.traversal_ids @> ARRAY[agent_group_authorizations.group_id::integer]")
.where(agent_group_authorizations: { agent_id: id })
+ .where('namespaces.traversal_ids @> ARRAY[?]', root_namespace.id)
end
def root_namespace
- Namespace.select("traversal_ids[1] AS root_id")
- .where("traversal_ids @> ARRAY(?)", project_namespace)
- .limit(1)
- end
-
- def project_namespace
- ::Project.select('namespace_id')
- .joins(:cluster_agents)
- .where(cluster_agents: { id: id })
- .limit(1)
+ project.root_namespace
end
+ strong_memoize_attr :root_namespace
end
end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index a2903bba6d2..9cae71809fd 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -23,7 +23,7 @@ module Clusters
has_many :projects, through: :cluster_projects, class_name: '::Project'
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
has_many :deployment_clusters
- has_many :deployments, inverse_of: :cluster
+ has_many :deployments, inverse_of: :cluster, through: :deployment_clusters
has_many :successful_deployments, -> { success }, class_name: 'Deployment'
has_many :environments, -> { distinct }, through: :deployments
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 6d17d7f495d..26412205899 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -427,7 +427,7 @@ class Commit
end
def cherry_pick_message(user)
- %Q{#{message}\n\n#{cherry_pick_description(user)}}
+ %{#{message}\n\n#{cherry_pick_description(user)}}
end
def revert_description(user)
@@ -439,7 +439,7 @@ class Commit
end
def revert_message(user)
- %Q{Revert "#{title.strip}"\n\n#{revert_description(user)}}
+ %{Revert "#{title.strip}"\n\n#{revert_description(user)}}
end
def reverts_commit?(commit, user)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 6dfea7ef9a7..f26831c1049 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -317,6 +317,16 @@ class CommitStatus < Ci::ApplicationRecord
ci_stage&.name
end
+ # For AiAction
+ def to_ability_name
+ 'build'
+ end
+
+ # For AiAction
+ def resource_parent
+ project
+ end
+
private
def unrecoverable_failure?
diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb
index 4d464f353ee..9215e15f07d 100644
--- a/app/models/commit_user_mention.rb
+++ b/app/models/commit_user_mention.rb
@@ -3,7 +3,7 @@
class CommitUserMention < UserMention
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :note
end
diff --git a/app/models/concerns/admin_changed_password_notifier.rb b/app/models/concerns/admin_changed_password_notifier.rb
index f6c2abc7e0f..957f4f6323a 100644
--- a/app/models/concerns/admin_changed_password_notifier.rb
+++ b/app/models/concerns/admin_changed_password_notifier.rb
@@ -30,12 +30,10 @@ module AdminChangedPasswordNotifier
extend ActiveSupport::Concern
included do
- after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification?
- end
+ # default value of this attribute is `nil`, so these emails are off by default
+ attr_accessor :allow_admin_changed_your_password_notification
- def initialize(*args, &block)
- @allow_admin_changed_your_password_notification = false # These emails are off by default
- super
+ after_update :send_admin_changed_your_password_notification, if: :send_admin_changed_your_password_notification?
end
def send_only_admin_changed_your_password_notification!
@@ -50,11 +48,11 @@ module AdminChangedPasswordNotifier
end
def allow_admin_changed_your_password_notification!
- @allow_admin_changed_your_password_notification = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ self.allow_admin_changed_your_password_notification = true
end
def send_admin_changed_your_password_notification?
self.class.send_password_change_notification && saved_change_to_encrypted_password? &&
- @allow_admin_changed_your_password_notification # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ allow_admin_changed_your_password_notification
end
end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
index c01399184ad..d268c32c088 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb
@@ -5,6 +5,8 @@ module Analytics
extend ActiveSupport::Concern
included do
+ include FromUnion
+
scope :by_stage_event_hash_id, ->(id) { where(stage_event_hash_id: id) }
scope :by_project_id, ->(id) { where(project_id: id) }
scope :by_group_id, ->(id) { where(group_id: id) }
@@ -20,7 +22,7 @@ module Analytics
# start_event_timestamp must be included in the ORDER BY clause for the duration
# calculation to work: SELECT end_event_timestamp - start_event_timestamp
keyset_order(
- :end_event_timestamp => { order_expression: arel_order(arel_table[:end_event_timestamp], direction), distinct: false },
+ :end_event_timestamp => { order_expression: arel_order(arel_table[:end_event_timestamp], direction), distinct: false, nullable: direction == :asc ? :nulls_last : :nulls_first },
issuable_id_column => { order_expression: arel_order(arel_table[issuable_id_column], direction), distinct: true },
:start_event_timestamp => { order_expression: arel_order(arel_table[:start_event_timestamp], direction), distinct: false }
)
diff --git a/app/models/concerns/application_setting_masked_attrs.rb b/app/models/concerns/application_setting_masked_attrs.rb
new file mode 100644
index 00000000000..14a7185e39e
--- /dev/null
+++ b/app/models/concerns/application_setting_masked_attrs.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Similar to MASK_PASSWORD mechanism we do for EE, see:
+# https://gitlab.com/gitlab-org/gitlab/-/blob/463bb1f855d71fadef931bd50f1692ee04f211a8/ee/app/models/ee/application_setting.rb#L15
+# but for non-EE attributes.
+module ApplicationSettingMaskedAttrs
+ MASK = '*****'
+
+ def ai_access_token=(value)
+ return if value == MASK
+
+ super
+ end
+end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 1d0ce594f63..e830594af11 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -4,7 +4,7 @@ module Awardable
extend ActiveSupport::Concern
included do
- has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :award_emoji, -> { includes(:user).order(:id) }, as: :awardable, inverse_of: :awardable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
if self < Participable
# By default we always load award_emoji user association
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
index d8417773dbd..a3bcc7bcbbc 100644
--- a/app/models/concerns/ci/partitionable.rb
+++ b/app/models/concerns/ci/partitionable.rb
@@ -31,6 +31,7 @@ module Ci
Ci::BuildTraceChunk
Ci::BuildTraceMetadata
Ci::BuildPendingState
+ Ci::JobAnnotation
Ci::JobArtifact
Ci::JobVariable
Ci::Pipeline
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 7a6076c7d2e..b10b318fb7c 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -4,7 +4,7 @@ module DiffPositionableNote
included do
before_validation :set_original_position, on: :create
- before_validation :update_position, on: :create, if: :on_text?, unless: :importing?
+ before_validation :update_position, on: :create, if: :should_update_position?, unless: :importing?
serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
@@ -37,10 +37,18 @@ module DiffPositionableNote
end
end
+ def should_update_position?
+ on_text? || on_file?
+ end
+
def on_text?
!!position&.on_text?
end
+ def on_file?
+ !!position&.on_file?
+ end
+
def on_image?
!!position&.on_image?
end
diff --git a/app/models/concerns/enums/abuse/category.rb b/app/models/concerns/enums/abuse/category.rb
new file mode 100644
index 00000000000..e024ed17e32
--- /dev/null
+++ b/app/models/concerns/enums/abuse/category.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Enums
+ module Abuse
+ module Category
+ def self.categories
+ {
+ spam: 0, # spamcheck
+ virus: 1, # VirusTotal
+ fraud: 2, # Arkos, Telesign
+ ci_cd: 3 # PVS
+ }
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index 778471eac8b..d798a13741f 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -69,6 +69,10 @@ module Enums
ci_sources.merge(sources.slice(:parent_pipeline))
end
+ def self.ci_and_security_orchestration_sources
+ ci_sources.merge(sources.slice(:security_orchestration_policy))
+ end
+
# Returns the `Hash` to use for creating the `config_sources` enum for
# `Ci::Pipeline`.
def self.config_sources
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 468ea26c51a..9d4b8328e8d 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -4,7 +4,6 @@ module HasUserType
extend ActiveSupport::Concern
USER_TYPES = {
- human_deprecated: nil,
human: 0,
support_bot: 1,
alert_bot: 2,
@@ -39,25 +38,20 @@ module HasUserType
# `service_account` allows instance/namespaces to configure a user for external integrations/automations
# `service_user` is an internal, `gitlab-com`-specific user type for integrations like suggested reviewers
- NON_INTERNAL_USER_TYPES = %w[human human_deprecated project_bot service_user service_account].freeze
+ NON_INTERNAL_USER_TYPES = %w[human project_bot service_user service_account].freeze
INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
included do
enum user_type: USER_TYPES
- scope :humans, -> { where(user_type: :human).or(where(user_type: :human_deprecated)) }
- # Override default scope to include temporary human type. See https://gitlab.com/gitlab-org/gitlab/-/issues/386474
- scope :human, -> { humans }
scope :bots, -> { where(user_type: BOT_USER_TYPES) }
- scope :without_bots, -> { humans.or(where(user_type: USER_TYPES.keys - BOT_USER_TYPES)) }
- scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
- scope :without_ghosts, -> { humans.or(where(user_type: USER_TYPES.keys - ['ghost'])) }
- scope :without_project_bot, -> { humans.or(where(user_type: USER_TYPES.keys - ['project_bot'])) }
- scope :human_or_service_user, -> { humans.or(where(user_type: :service_user)) }
+ scope :without_bots, -> { where(user_type: USER_TYPES.keys - BOT_USER_TYPES) }
+ scope :non_internal, -> { where(user_type: NON_INTERNAL_USER_TYPES) }
+ scope :without_ghosts, -> { where(user_type: USER_TYPES.keys - ['ghost']) }
+ scope :without_project_bot, -> { where(user_type: USER_TYPES.keys - ['project_bot']) }
+ scope :human_or_service_user, -> { where(user_type: %i[human service_user]) }
- def human?
- super || human_deprecated? || user_type.nil?
- end
+ validates :user_type, presence: true
end
def bot?
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 8926e805d8d..9a513ea0e5b 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -659,6 +659,7 @@ module Issuable
def read_ability_for(participable_source)
return super if participable_source == self
+ return super if participable_source.is_a?(Note) && participable_source.system?
name = participable_source.try(:issuable_ability_name) || :read_issuable_participables
diff --git a/app/models/concerns/issues/forbid_issue_type_column_usage.rb b/app/models/concerns/issues/forbid_issue_type_column_usage.rb
new file mode 100644
index 00000000000..46a8a0278d9
--- /dev/null
+++ b/app/models/concerns/issues/forbid_issue_type_column_usage.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699
+module Issues
+ module ForbidIssueTypeColumnUsage
+ extend ActiveSupport::Concern
+
+ ForbiddenColumnUsed = Class.new(StandardError)
+
+ included do
+ WorkItems::Type.base_types.each do |base_type, _value|
+ define_method "#{base_type}?".to_sym do
+ error_message = <<~ERROR
+ `#{model_name.element}.#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column,
+ its usage is forbidden. You should use the `work_item_types` table instead.
+
+ # Before
+
+ #{model_name.element}.#{base_type}? => true
+
+ # After
+
+ #{model_name.element}.work_item_type.#{base_type}? => true
+
+ More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
+ ERROR
+
+ raise ForbiddenColumnUsed, error_message
+ end
+
+ define_singleton_method base_type.to_sym do
+ error = ForbiddenColumnUsed.new(
+ <<~ERROR
+ `#{name}.#{base_type}` uses the `issue_type` column underneath. As we want to remove the column,
+ its usage is forbidden. You should use the `work_item_types` table instead.
+
+ # Before
+
+ #{name}.#{base_type}
+
+ # After
+
+ #{name}.with_issue_type(:#{base_type})
+
+ More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
+ ERROR
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
+ error,
+ method_name: "#{name}.#{base_type}"
+ )
+
+ with_issue_type(base_type.to_sym)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 65e7f734233..5c91f2460c4 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -169,7 +169,9 @@ module Noteable
def expire_note_etag_cache
return unless discussions_rendered_on_frontend?
return unless etag_caching_enabled?
- return unless project.present?
+
+ # TODO: We need to figure out a way to make ETag caching work for group-level work items
+ return if is_a?(Issue) && project.nil?
Gitlab::EtagCaching::Store.new.touch(note_etag_key)
end
diff --git a/app/models/concerns/packages/downloadable.rb b/app/models/concerns/packages/downloadable.rb
new file mode 100644
index 00000000000..011f5ddda9c
--- /dev/null
+++ b/app/models/concerns/packages/downloadable.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Packages
+ module Downloadable
+ extend ActiveSupport::Concern
+
+ def touch_last_downloaded_at
+ ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
+ update_column(:last_downloaded_at, Time.zone.now)
+ end
+ end
+ end
+end
+
+Packages::Downloadable.prepend_mod
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index b910c0ab5c2..76c733b1c0b 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -114,6 +114,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:infrastructure_access_level, value)
end
+ def model_experiments_access_level=(value)
+ write_feature_attribute_string(:model_experiments_access_level, value)
+ end
+
# TODO: Remove this method after we drop support for project create/edit APIs to set the
# container_registry_enabled attribute. They can instead set the container_registry_access_level
# attribute.
diff --git a/app/models/concerns/recoverable_by_any_email.rb b/app/models/concerns/recoverable_by_any_email.rb
new file mode 100644
index 00000000000..c946e7e78c6
--- /dev/null
+++ b/app/models/concerns/recoverable_by_any_email.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# Concern that overrides the Devise methods
+# to send reset password instructions to any verified user email
+module RecoverableByAnyEmail
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def send_reset_password_instructions(attributes = {})
+ email = attributes.delete(:email)
+ super unless email
+
+ recoverable = by_email_with_errors(email)
+ recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted?
+ recoverable
+ end
+
+ private
+
+ def by_email_with_errors(email)
+ record = find_by_any_email(email, confirmed: true) || new
+ record.errors.add(:email, :invalid) unless record.persisted?
+ record
+ end
+ end
+
+ def send_reset_password_instructions(opts = {})
+ token = set_reset_password_token
+ send_reset_password_instructions_notification(token, opts)
+
+ token
+ end
+
+ private
+
+ def send_reset_password_instructions_notification(token, opts = {})
+ send_devise_notification(:reset_password_instructions, token, opts)
+ end
+end
diff --git a/app/models/concerns/sanitizable.rb b/app/models/concerns/sanitizable.rb
index 653d7a4875d..d05ce389ebf 100644
--- a/app/models/concerns/sanitizable.rb
+++ b/app/models/concerns/sanitizable.rb
@@ -48,11 +48,11 @@ module Sanitizable
# This method raises an exception on failure so perform this
# last if multiple errors should be returned.
- Gitlab::Utils.check_path_traversal!(input.to_s)
+ Gitlab::PathTraversal.check_path_traversal!(input.to_s)
rescue Gitlab::Utils::DoubleEncodingError
record.errors.add(attr, 'cannot contain escaped components')
- rescue Gitlab::Utils::PathTraversalAttackError
+ rescue Gitlab::PathTraversal::PathTraversalAttackError
record.errors.add(attr, "cannot contain a path traversal component")
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index fba923e843a..6550c5a94a0 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -2,6 +2,7 @@
module Spammable
extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
class_methods do
def attr_spammable(attr, options = {})
@@ -46,14 +47,23 @@ module Spammable
end
def needs_recaptcha!
- self.needs_recaptcha = true
+ if self.supports_recaptcha?
+ self.needs_recaptcha = true
+ else
+ self.spam!
+ end
+ end
+
+ # Override in Spammable if recaptcha is supported
+ def supports_recaptcha?
+ false
end
##
# Indicates if a recaptcha should be rendered before allowing this model to be saved.
#
def render_recaptcha?
- return false unless Gitlab::Recaptcha.enabled?
+ return false unless Gitlab::Recaptcha.enabled? && supports_recaptcha?
return false if self.errors.count > 1 # captcha should not be rendered if are still other errors
@@ -70,7 +80,7 @@ module Spammable
end
def invalidate_if_spam
- if needs_recaptcha? && Gitlab::Recaptcha.enabled?
+ if needs_recaptcha? && Gitlab::Recaptcha.enabled? && supports_recaptcha?
recaptcha_error!
elsif needs_recaptcha? || spam?
unrecoverable_spam_error!
@@ -84,12 +94,24 @@ module Spammable
end
def unrecoverable_spam_error!
- self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam and has been discarded.") \
+ self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content to proceed.") \
% { spammable_entity_type: spammable_entity_type })
end
def spammable_entity_type
- self.class.name.underscore
+ case self
+ when Issue
+ _('issue')
+ when MergeRequest
+ _('merge request')
+ when Note
+ _('comment')
+ when Snippet
+ _('snippet')
+ else
+ self.class.model_name.human.downcase
+ end
end
def spam_title
@@ -117,8 +139,18 @@ module Spammable
end
# Override in Spammable if further checks are necessary
- def check_for_spam?(user:)
- true
+ def check_for_spam?(*)
+ spammable_attribute_changed?
+ end
+
+ def spammable_attribute_changed?
+ (changed & self.class.spammable_attrs.to_h.keys).any?
+ end
+
+ def check_for_spam(user:, action:, extra_features: {})
+ strong_memoize_with(:check_for_spam, user, action, extra_features) do
+ Spam::SpamActionService.new(spammable: self, user: user, action: action, extra_features: extra_features).execute
+ end
end
# Override in Spammable if differs
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index e418842a30b..b73ed937b5d 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -99,7 +99,7 @@ module Storage
Gitlab::GitalyClient::NamespaceService.allow do
if gitlab_shell.mv_namespace(repository_storage, full_path, new_path)
- Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}")
+ Gitlab::AppLogger.info %(Namespace directory "#{full_path}" moved to "#{new_path}")
# Remove namespace directory async with delay so
# GitLab has time to remove all projects first
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index ef31bedc3a8..f9fa4bd212c 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -10,15 +10,19 @@ class DeployKey < Key
has_many :projects, through: :deploy_keys_projects
has_many :deploy_keys_projects_with_write_access, -> { with_write_access }, class_name: "DeployKeysProject", inverse_of: :deploy_key
+ has_many :deploy_keys_projects_with_readonly_access, -> { with_readonly_access }, class_name: "DeployKeysProject", inverse_of: :deploy_key
has_many :projects_with_write_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_write_access, source: :project
+ has_many :projects_with_readonly_access, -> { includes(:route) }, class_name: 'Project', through: :deploy_keys_projects_with_readonly_access, source: :project
has_many :protected_branch_push_access_levels, class_name: '::ProtectedBranch::PushAccessLevel', inverse_of: :deploy_key
has_many :protected_tag_create_access_levels, class_name: '::ProtectedTag::CreateAccessLevel', inverse_of: :deploy_key
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where(deploy_keys_projects: { project_id: projects }) }
scope :with_write_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_write_access) }
+ scope :with_readonly_access, -> { joins(:deploy_keys_projects).merge(DeployKeysProject.with_readonly_access) }
scope :are_public, -> { where(public: true) }
scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) }
scope :including_projects_with_write_access, -> { includes(:projects_with_write_access) }
+ scope :including_projects_with_readonly_access, -> { includes(:projects_with_readonly_access) }
accepts_nested_attributes_for :deploy_keys_projects, reject_if: :reject_deploy_keys_projects?
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index 363ef0b1c9a..e114b7297eb 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -5,6 +5,7 @@ class DeployKeysProject < ApplicationRecord
belongs_to :deploy_key, inverse_of: :deploy_keys_projects
scope :in_project, ->(project) { where(project: project) }
scope :with_write_access, -> { where(can_push: true) }
+ scope :with_readonly_access, -> { where(can_push: false) }
accepts_nested_attributes_for :deploy_key
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index f3ee21ea4e0..1e3a80087c8 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -16,7 +16,6 @@ class Deployment < ApplicationRecord
belongs_to :project, optional: false
belongs_to :environment, optional: false
- belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true
belongs_to :user
belongs_to :deployable, polymorphic: true, optional: true, inverse_of: :deployment # rubocop:disable Cop/PolymorphicAssociations
has_many :deployment_merge_requests
@@ -35,6 +34,7 @@ class Deployment < ApplicationRecord
delegate :name, to: :environment, prefix: true
delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true
+ delegate :cluster, to: :deployment_cluster, allow_nil: true
scope :for_iid, -> (project, iid) { where(project: project, iid: iid) }
scope :for_environment, -> (environment) { where(environment_id: environment) }
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
index 33c5dc15fa4..39077fdbcb1 100644
--- a/app/models/design_management/repository.rb
+++ b/app/models/design_management/repository.rb
@@ -34,3 +34,5 @@ module DesignManagement
end
end
end
+
+DesignManagement::Repository.prepend_mod_with('DesignManagement::Repository')
diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb
index 87899f65cb1..7d0cd72e9eb 100644
--- a/app/models/design_user_mention.rb
+++ b/app/models/design_user_mention.rb
@@ -3,7 +3,7 @@
class DesignUserMention < UserMention
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :design, class_name: 'DesignManagement::Design'
belongs_to :note
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index e2ee951522d..c4ccb9ef4f5 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -37,8 +37,8 @@ class DiffDiscussion < Discussion
def reply_attributes
super.merge(
- original_position: Gitlab::Json.dump(original_position),
- position: Gitlab::Json.dump(position)
+ original_position: Gitlab::Json.dump(original_position.to_h),
+ position: Gitlab::Json.dump(position.to_h)
)
end
diff --git a/app/models/diff_note_position.rb b/app/models/diff_note_position.rb
index a25b0def643..5e9f0100e62 100644
--- a/app/models/diff_note_position.rb
+++ b/app/models/diff_note_position.rb
@@ -43,7 +43,6 @@ class DiffNotePosition < ApplicationRecord
def self.position_to_attrs(position)
position_attrs = position.to_h
position_attrs[:diff_content_type] = position_attrs.delete(:position_type)
- position_attrs.delete(:line_range)
- position_attrs
+ position_attrs.except(:line_range, :ignore_whitespace_change)
end
end
diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb
index 05552e83700..31118791075 100644
--- a/app/models/diff_viewer/base.rb
+++ b/app/models/diff_viewer/base.rb
@@ -88,7 +88,7 @@ module DiffViewer
{
viewer: switcher_title,
reason: render_error_reason,
- options: Gitlab::Utils.to_exclusive_sentence(render_error_options)
+ options: Gitlab::Sentence.to_exclusive_sentence(render_error_options)
}
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 83c85f30178..dc4794ed3cd 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -13,23 +13,23 @@ class Discussion
attr_reader :context_noteable
attr_accessor :notes
- delegate :created_at,
- :project,
- :author,
- :noteable,
- :commit_id,
- :confidential?,
- :for_commit?,
- :for_design?,
- :for_merge_request?,
- :noteable_ability_name,
- :to_ability_name,
- :editable?,
- :resolved_by_id,
- :system_note_visible_for?,
- :resource_parent,
- :save,
- to: :first_note
+ delegate :created_at,
+ :project,
+ :author,
+ :noteable,
+ :commit_id,
+ :confidential?,
+ :for_commit?,
+ :for_design?,
+ :for_merge_request?,
+ :noteable_ability_name,
+ :to_ability_name,
+ :editable?,
+ :resolved_by_id,
+ :system_note_visible_for?,
+ :resource_parent,
+ :save,
+ to: :first_note
def declarative_policy_delegate
first_note
diff --git a/app/models/environment.rb b/app/models/environment.rb
index f1de41674c6..8480272eced 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -15,6 +15,7 @@ class Environment < ApplicationRecord
belongs_to :project, optional: false
belongs_to :merge_request, optional: true
+ belongs_to :cluster_agent, class_name: 'Clusters::Agent', optional: true, inverse_of: :environments
use_fast_destroy :all_deployments
nullify_if_blank :external_url
@@ -35,12 +36,12 @@ class Environment < ApplicationRecord
Deployment::FINISHED_STATUSES.each do |status|
has_one :"last_#{status}_deployment", -> { where(status: status).ordered },
- class_name: 'Deployment', inverse_of: :environment
+ class_name: 'Deployment', inverse_of: :environment
end
Deployment::UPCOMING_STATUSES.each do |status|
has_one :"last_#{status}_deployment", -> { where(status: status).ordered_as_upcoming },
- class_name: 'Deployment', inverse_of: :environment
+ class_name: 'Deployment', inverse_of: :environment
end
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
@@ -52,22 +53,22 @@ class Environment < ApplicationRecord
after_save :clear_reactive_cache!
validates :name,
- presence: true,
- uniqueness: { scope: :project_id },
- length: { maximum: 255 },
- format: { with: Gitlab::Regex.environment_name_regex,
- message: Gitlab::Regex.environment_name_regex_message }
+ presence: true,
+ uniqueness: { scope: :project_id },
+ length: { maximum: 255 },
+ format: { with: Gitlab::Regex.environment_name_regex,
+ message: Gitlab::Regex.environment_name_regex_message }
validates :slug,
- presence: true,
- uniqueness: { scope: :project_id },
- length: { maximum: 24 },
- format: { with: Gitlab::Regex.environment_slug_regex,
- message: Gitlab::Regex.environment_slug_regex_message }
+ presence: true,
+ uniqueness: { scope: :project_id },
+ length: { maximum: 24 },
+ format: { with: Gitlab::Regex.environment_slug_regex,
+ message: Gitlab::Regex.environment_slug_regex_message }
validates :external_url,
- length: { maximum: 255 },
- allow_nil: true
+ length: { maximum: 255 },
+ allow_nil: true
# Currently, the tier presence is validaed for newly created environments.
# After the `BackfillEnvironmentTiers` background migration has been completed, we should remove `on: :create`.
@@ -236,8 +237,7 @@ class Environment < ApplicationRecord
def self.nested
group('COALESCE(environment_type, id::text)', 'COALESCE(environment_type, name)')
- .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS name',
- 'COUNT(*) AS size', 'MAX(id) AS last_id')
+ .select('COALESCE(environment_type, id::text), COALESCE(environment_type, name) AS name', 'COUNT(*) AS size', 'MAX(id) AS last_id')
.order('name ASC')
end
diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb
index b02074849a1..f795585dfc5 100644
--- a/app/models/generic_commit_status.rb
+++ b/app/models/generic_commit_status.rb
@@ -3,9 +3,7 @@
class GenericCommitStatus < CommitStatus
EXTERNAL_STAGE_IDX = 1_000_000
- validates :target_url, addressable_url: true,
- length: { maximum: 255 },
- allow_nil: true
+ validates :target_url, addressable_url: true, length: { maximum: 255 }, allow_nil: true
validate :name_uniqueness_across_types, unless: :importing?
# GitHub compatible API
diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb
index 71abfd3f6da..37e69102521 100644
--- a/app/models/grafana_integration.rb
+++ b/app/models/grafana_integration.rb
@@ -11,8 +11,8 @@ class GrafanaIntegration < ApplicationRecord
before_validation :check_token_changes
validates :grafana_url,
- length: { maximum: 1024 },
- addressable_url: { enforce_sanitization: true, ascii_only: true }
+ length: { maximum: 1024 },
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
validates :encrypted_token, :project, presence: true
diff --git a/app/models/group.rb b/app/models/group.rb
index ab8e0101684..85971c48567 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -38,7 +38,7 @@ class Group < Namespace
has_many :users, through: :group_members
has_many :owners,
-> { where(members: { access_level: Gitlab::Access::OWNER }) },
- through: :group_members,
+ through: :all_group_members,
source: :user
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
@@ -92,7 +92,7 @@ class Group < Namespace
has_many :badges, class_name: 'GroupBadge'
# AR defaults to nullify when trying to delete via has_many associations unless we set dependent: :delete_all
- has_many :organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :crm_organizations, class_name: 'CustomerRelations::Organization', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :contacts, class_name: 'CustomerRelations::Contact', inverse_of: :group, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :cluster_groups, class_name: 'Clusters::Group'
@@ -152,17 +152,19 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :name,
- html_safety: true,
- format: { with: Gitlab::Regex.group_name_regex,
- message: Gitlab::Regex.group_name_regex_message },
- if: :name_changed?
+ html_safety: true,
+ format: {
+ with: Gitlab::Regex.group_name_regex,
+ message: Gitlab::Regex.group_name_regex_message
+ },
+ if: :name_changed?
validates :group_feature, presence: true
add_authentication_token_field :runners_token,
- encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption) ? :optional : :required },
- format_with_prefix: :runners_token_prefix,
- require_prefix_for_validation: true
+ encrypted: :required,
+ format_with_prefix: :runners_token_prefix,
+ require_prefix_for_validation: true
after_create :post_create_hook
after_create -> { create_or_load_association(:group_feature) }
@@ -187,6 +189,8 @@ class Group < Namespace
Group.from_union([by_id(ids), by_id(ids_by_full_path), where('LOWER(path) IN (?)', paths.map(&:downcase))])
end
+ scope :excluding_groups, ->(groups) { where.not(id: groups) }
+
scope :for_authorized_group_members, -> (user_ids) do
joins(:group_members)
.where(members: { user_id: user_ids })
@@ -469,7 +473,7 @@ class Group < Namespace
def has_owner?(user)
return false unless user
- members_with_parents.owners.exists?(user_id: user)
+ members_with_parents.all_owners.exists?(user_id: user)
end
def blocked_owners
@@ -490,35 +494,23 @@ class Group < Namespace
# Excludes non-direct owners for top-level group
# Excludes project_bots
def last_owner?(user)
- has_owner?(user) && member_owners_excluding_project_bots.size == 1
- end
+ return false unless user
- def member_last_owner?(member)
- return member.last_owner unless member.last_owner.nil?
+ all_owners = member_owners_excluding_project_bots
- last_owner?(member.user)
+ all_owners.size == 1 && all_owners.first.user_id == user.id
end
# Excludes non-direct owners for top-level group
# Excludes project_bots
def member_owners_excluding_project_bots
- if root?
- members
- else
- members_with_parents
- end.owners.merge(User.without_project_bot)
- end
+ members_from_hiearchy = if root?
+ members.non_minimal_access.without_invites_and_requests
+ else
+ members_with_parents(only_active_users: false)
+ end
- def single_blocked_owner?
- blocked_owners.size == 1
- end
-
- def member_last_blocked_owner?(member)
- return member.last_blocked_owner unless member.last_blocked_owner.nil?
-
- return false if member_owners_excluding_project_bots.any?
-
- single_blocked_owner? && blocked_owners.exists?(user_id: member.user)
+ members_from_hiearchy.all_owners.left_outer_joins(:user).merge(User.without_project_bot)
end
def ldap_synced?
@@ -606,7 +598,7 @@ class Group < Namespace
members_from_self_and_ancestor_group_shares]).authorizable
end
- def members_with_parents
+ def members_with_parents(only_active_users: true)
# Avoids an unnecessary SELECT when the group has no parents
source_ids =
if has_parent?
@@ -615,11 +607,16 @@ class Group < Namespace
id
end
- group_hierarchy_members = GroupMember.active_without_invites_and_requests
- .non_minimal_access
+ group_hierarchy_members = GroupMember.non_minimal_access
.where(source_id: source_ids)
.select(*GroupMember.cached_column_list)
+ group_hierarchy_members = if only_active_users
+ group_hierarchy_members.active_without_invites_and_requests
+ else
+ group_hierarchy_members.without_invites_and_requests
+ end
+
GroupMember.from_union([group_hierarchy_members,
members_from_self_and_ancestor_group_shares])
end
@@ -972,9 +969,11 @@ class Group < Namespace
end
def max_member_access(user_ids)
- Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User),
- resource_ids: user_ids,
- default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do |user_ids|
members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
end
end
@@ -1035,8 +1034,7 @@ class Group < Namespace
# the respective group_group_links.group_access.
member_columns = GroupMember.attribute_names.map do |column_name|
if column_name == 'access_level'
- smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]],
- 'access_level')
+ smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level')
else
group_member_table[column_name]
end
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index fdb8fb9ed75..dba52aa51cd 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -10,8 +10,7 @@ class GroupGroupLink < ApplicationRecord
validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id],
message: N_('The group has already been shared with this group') }
validates :shared_with_group, presence: true
- validates :group_access, inclusion: { in: Gitlab::Access.all_values },
- presence: true
+ validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true
scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 5ccbc926a71..6dc1c9f290a 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -9,23 +9,23 @@ class WebHook < ApplicationRecord
SECRET_MASK = '************'
attr_encrypted :token,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32
attr_encrypted :url,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32
attr_encrypted :url_variables,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- marshal: true,
- marshaler: ::Gitlab::Json,
- encode: false,
- encode_iv: false
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ marshal: true,
+ marshaler: ::Gitlab::Json,
+ encode: false,
+ encode_iv: false
has_many :web_hook_logs
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
index e5b27009115..36658513275 100644
--- a/app/models/import_failure.rb
+++ b/app/models/import_failure.rb
@@ -3,9 +3,11 @@
class ImportFailure < ApplicationRecord
belongs_to :project
belongs_to :group
+ belongs_to :user
- validates :project, presence: true, unless: :group
- validates :group, presence: true, unless: :project
+ validates :project, :group, absence: true, if: :user
+ validates :project, :user, absence: true, if: :group
+ validates :group, :user, absence: true, if: :project
validates :external_identifiers, json_schema: { filename: "import_failure_external_identifiers" }
scope :with_external_identifiers, -> { where.not(external_identifiers: {}) }
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 860739fe5aa..f2f242136ab 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -18,17 +18,17 @@ class Integration < ApplicationRecord
self.inheritance_column = :type_new
INTEGRATION_NAMES = %w[
- asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
+ asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker datadog discord
drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity
+ pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram
unify_circuit webex_teams youtrack zentao
].freeze
# TODO Shimo is temporary disabled on group and instance-levels.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/345677
PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[
- apple_app_store google_play jenkins shimo
+ apple_app_store gitlab_slack_application google_play jenkins shimo
].freeze
# Fake integrations to help with local development.
@@ -55,13 +55,13 @@ class Integration < ApplicationRecord
SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly'
attr_encrypted :properties,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- marshal: true,
- marshaler: ::Gitlab::Json,
- encode: false,
- encode_iv: false
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ marshal: true,
+ marshaler: ::Gitlab::Json,
+ encode: false,
+ encode_iv: false
# Handle assignment of props with symbol keys.
# To do this correctly, we need to call the method generated by attr_encrypted.
@@ -81,6 +81,7 @@ class Integration < ApplicationRecord
attribute :commit_events, default: true
attribute :confidential_issues_events, default: true
attribute :confidential_note_events, default: true
+ attribute :deployment_events, default: false
attribute :issues_events, default: true
attribute :job_events, default: true
attribute :merge_requests_events, default: true
@@ -282,7 +283,6 @@ class Integration < ApplicationRecord
# Returns a list of available integration names.
# Example: ["asana", ...]
- # @deprecated
def self.available_integration_names(include_project_specific: true, include_dev: true)
names = integration_names
names += project_specific_integration_names if include_project_specific
@@ -302,7 +302,9 @@ class Integration < ApplicationRecord
end
def self.project_specific_integration_names
- PROJECT_SPECIFIC_INTEGRATION_NAMES
+ names = PROJECT_SPECIFIC_INTEGRATION_NAMES.dup
+ names.delete('gitlab_slack_application') unless Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env?
+ names
end
# Returns a list of available integration types.
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index 5e502cce927..a4036a82cec 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -15,23 +15,28 @@ module Integrations
validates :app_store_key_id, presence: true, format: { with: KEY_ID_REGEX }
validates :app_store_private_key, presence: true, certificate_key: true
validates :app_store_private_key_file_name, presence: true
+ validates :app_store_protected_refs, inclusion: [true, false]
end
field :app_store_issuer_id,
- section: SECTION_TYPE_CONNECTION,
- required: true,
- title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') }
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Issuer ID.') }
field :app_store_key_id,
- section: SECTION_TYPE_CONNECTION,
- required: true,
- title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') }
-
- field :app_store_private_key_file_name,
- section: SECTION_TYPE_CONNECTION
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') }
+ field :app_store_private_key_file_name, section: SECTION_TYPE_CONNECTION
field :app_store_private_key, api_only: true
+ field :app_store_protected_refs,
+ type: 'checkbox',
+ section: SECTION_TYPE_CONFIGURATION,
+ title: -> { s_('AppleAppStore|Protected branches and tags only') },
+ checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') }
+
def title
'Apple App Store Connect'
end
@@ -87,8 +92,9 @@ module Integrations
end
end
- def ci_variables
+ def ci_variables(protected_ref:)
return [] unless activated?
+ return [] if app_store_protected_refs && !protected_ref
[
{ key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: app_store_issuer_id, masked: true, public: false },
@@ -100,6 +106,11 @@ module Integrations
]
end
+ def initialize_properties
+ super
+ self.app_store_protected_refs = true if app_store_protected_refs.nil?
+ end
+
private
def client
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index 963ba918089..4477f3d207f 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -35,9 +35,9 @@ module Integrations
boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
validates :webhook,
- presence: true,
- public_url: true,
- if: -> (integration) { integration.activated? && integration.requires_webhook? }
+ presence: true,
+ public_url: true,
+ if: -> (integration) { integration.activated? && integration.requires_webhook? }
validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true, if: :activated?
validate :validate_channel_limit, if: :activated?
diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb
index 60a3105d1c0..b17e28bb6c6 100644
--- a/app/models/integrations/chat_message/push_message.rb
+++ b/app/models/integrations/chat_message/push_message.rb
@@ -82,12 +82,12 @@ module Integrations
if ref_type == 'tag'
"#{project_url}/-/tags/#{ref}"
else
- "#{project_url}/commits/#{ref}"
+ "#{project_url}/-/commits/#{ref}"
end
end
def compare_url
- "#{project_url}/compare/#{before}...#{after}"
+ "#{project_url}/-/compare/#{before}...#{after}"
end
def ref_link
diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb
new file mode 100644
index 00000000000..7cc05d41e14
--- /dev/null
+++ b/app/models/integrations/clickup.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Clickup < BaseIssueTracker
+ include HasIssueTrackerFields
+
+ validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
+
+ def reference_pattern(*)
+ @reference_pattern ||= /((#|CU-)(?<issue>[a-z0-9]+)|(?<issue>[A-Z0-9_]{2,10}-\d+))\b/
+ end
+
+ def title
+ 'ClickUp'
+ end
+
+ def description
+ s_("IssueTracker|Use Clickup as this project's issue tracker.")
+ end
+
+ def help
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'),
+ Rails.application.routes.url_helpers.help_page_url('user/project/integrations/clickup'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ format(s_(
+ "IssueTracker|Use ClickUp as this project's issue tracker. %{docs_link}"
+ ).html_safe, docs_link: docs_link.html_safe)
+ end
+
+ def self.to_param
+ 'clickup'
+ end
+
+ def fields
+ super.select { _1.name.in?(%w[project_url issues_url]) }
+ end
+ end
+end
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index 3b3c7d8f2cd..c7306209174 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -40,7 +40,7 @@ module Integrations
ERB::Util.html_escape(
s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.')
) % {
- linkOpen: %Q{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
+ linkOpen: %{<a href="#{URL_API_KEYS_DOCS}" target="_blank" rel="noopener noreferrer">}.html_safe,
linkClose: '</a>'.html_safe
}
end,
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index c903e8d9eb8..ad82f1b916f 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -58,9 +58,9 @@ module Integrations
when Integrations::ChatMessage::NoteMessage
message.target
when Integrations::ChatMessage::IssueMessage
- "issue #{Issue.reference_prefix}#{message.issue_iid}"
+ "issue #{message.project_name}#{Issue.reference_prefix}#{message.issue_iid}"
when Integrations::ChatMessage::MergeMessage
- "merge request #{MergeRequest.reference_prefix}#{message.merge_request_iid}"
+ "merge request #{message.project_name}#{MergeRequest.reference_prefix}#{message.merge_request_iid}"
when Integrations::ChatMessage::PushMessage
"push #{message.project_name}_#{message.ref}"
when Integrations::ChatMessage::PipelineMessage
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 2520d3bfc9c..4e0c2dde13b 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -14,6 +14,14 @@ module Integrations
ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze
ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze
+ API_ENDPOINTS = {
+ find_issue: "/rest/api/2/issue/%s",
+ server_info: "/rest/api/2/serverInfo",
+ transition_issue: "/rest/api/2/issue/%s/transitions",
+ issue_comments: "/rest/api/2/issue/%s/comment",
+ link_remote_issue: "/rest/api/2/issue/%s/remotelink"
+ }.freeze
+
SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
@@ -32,11 +40,11 @@ module Integrations
validate :validate_jira_cloud_auth_type_is_basic, if: :activated?
validates :jira_issue_transition_id,
- format: {
- with: Gitlab::Regex.jira_transition_id_regex,
- message: ->(*_) { s_("JiraService|IDs must be a list of numbers that can be split with , or ;") }
- },
- allow_blank: true
+ format: {
+ with: Gitlab::Regex.jira_transition_id_regex,
+ message: ->(*_) { s_("JiraService|IDs must be a list of numbers that can be split with , or ;") }
+ },
+ allow_blank: true
# Jira Cloud version is deprecating authentication via username and password.
# We should use username/password for Jira Server and email/api_token for Jira Cloud,
@@ -52,57 +60,57 @@ module Integrations
self.field_storage = :data_fields
field :url,
- section: SECTION_TYPE_CONNECTION,
- required: true,
- title: -> { s_('JiraService|Web URL') },
- help: -> { s_('JiraService|Base URL of the Jira instance') },
- placeholder: 'https://jira.example.com',
- exposes_secrets: true
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Web URL') },
+ help: -> { s_('JiraService|Base URL of the Jira instance') },
+ placeholder: 'https://jira.example.com',
+ exposes_secrets: true
field :api_url,
- section: SECTION_TYPE_CONNECTION,
- title: -> { s_('JiraService|Jira API URL') },
- help: -> { s_('JiraService|If different from the Web URL') },
- exposes_secrets: true
+ section: SECTION_TYPE_CONNECTION,
+ title: -> { s_('JiraService|Jira API URL') },
+ help: -> { s_('JiraService|If different from the Web URL') },
+ exposes_secrets: true
field :jira_auth_type,
- type: 'select',
- required: true,
- section: SECTION_TYPE_CONNECTION,
- title: -> { s_('JiraService|Authentication type') },
- choices: -> {
- [
- [s_('JiraService|Basic'), AUTH_TYPE_BASIC],
- [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT]
- ]
- }
+ type: 'select',
+ required: true,
+ section: SECTION_TYPE_CONNECTION,
+ title: -> { s_('JiraService|Authentication type') },
+ choices: -> {
+ [
+ [s_('JiraService|Basic'), AUTH_TYPE_BASIC],
+ [s_('JiraService|Jira personal access token (Jira Data Center and Jira Server only)'), AUTH_TYPE_PAT]
+ ]
+ }
field :username,
- section: SECTION_TYPE_CONNECTION,
- required: false,
- title: -> { s_('JiraService|Email or username') },
- help: -> { s_('JiraService|Only required for Basic authentication. Email for Jira Cloud or username for Jira Data Center and Jira Server') }
+ section: SECTION_TYPE_CONNECTION,
+ required: false,
+ title: -> { s_('JiraService|Email or username') },
+ help: -> { s_('JiraService|Email for Jira Cloud or username for Jira Data Center and Jira Server') }
field :password,
- section: SECTION_TYPE_CONNECTION,
- required: true,
- title: -> { s_('JiraService|Password or API token') },
- non_empty_password_title: -> { s_('JiraService|New API token, password, or Jira personal access token') },
- non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') },
- help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') },
- is_secret: true
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|API token or password') },
+ non_empty_password_title: -> { s_('JiraService|New API token or password') },
+ non_empty_password_help: -> { s_('JiraService|Leave blank to use your current configuration') },
+ help: -> { s_('JiraService|API token for Jira Cloud or password for Jira Data Center and Jira Server') },
+ is_secret: true
field :jira_issue_regex,
- section: SECTION_TYPE_CONFIGURATION,
- required: false,
- title: -> { s_('JiraService|Jira issue regex') },
- help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') }
+ section: SECTION_TYPE_CONFIGURATION,
+ required: false,
+ title: -> { s_('JiraService|Jira issue regex') },
+ help: -> { s_('JiraService|Use regular expression to match Jira issue keys.') }
field :jira_issue_prefix,
- section: SECTION_TYPE_CONFIGURATION,
- required: false,
- title: -> { s_('JiraService|Jira issue prefix') },
- help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') }
+ section: SECTION_TYPE_CONFIGURATION,
+ required: false,
+ title: -> { s_('JiraService|Jira issue prefix') },
+ help: -> { s_('JiraService|Use a prefix to match Jira issue keys.') }
field :jira_issue_transition_id, api_only: true
@@ -277,7 +285,9 @@ module Integrations
expands << 'transitions' if transitions
options = { expand: expands.join(',') } if expands.any?
- jira_request { client.Issue.find(issue_key, options || {}) }
+ path = API_ENDPOINTS[:find_issue] % issue_key
+
+ jira_request(path) { client.Issue.find(issue_key, options || {}) }
end
def close_issue(entity, external_issue, current_user)
@@ -374,9 +384,9 @@ module Integrations
private
def jira_issue_match_regex
- match_regex = (jira_issue_regex.presence || Gitlab::Regex.jira_issue_key_regex)
+ return /\b#{jira_issue_prefix}(?<issue>#{Gitlab::Regex.jira_issue_key_regex})/ if jira_issue_regex.blank?
- /\b#{jira_issue_prefix}(?<issue>#{match_regex})/
+ Gitlab::UntrustedRegexp.new("\\b#{jira_issue_prefix}(?P<issue>#{jira_issue_regex})")
end
def parse_project_from_issue_key(issue_key)
@@ -389,7 +399,7 @@ module Integrations
def server_info
strong_memoize(:server_info) do
- client_url.present? ? jira_request { client.ServerInfo.all.attrs } : nil
+ client_url.present? ? jira_request(API_ENDPOINTS[:server_info]) { client.ServerInfo.all.attrs } : nil
end
end
@@ -419,7 +429,8 @@ module Integrations
true
rescue StandardError => e
- log_exception(e, message: 'Issue transition failed', client_url: client_url)
+ path = API_ENDPOINTS[:transition_issue] % issue.id
+ log_exception(e, message: 'Issue transition failed', client_url: client_url, client_path: path, client_status: '400')
false
end
@@ -518,7 +529,8 @@ module Integrations
end
def comment_exists?(issue, message)
- comments = jira_request { issue.comments }
+ path = API_ENDPOINTS[:issue_comments] % issue.id
+ comments = jira_request(path) { issue.comments }
comments.present? && comments.any? { |comment| comment.body.include?(message) }
end
@@ -526,14 +538,16 @@ module Integrations
def send_message(issue, message, remote_link_props)
return unless client_url.present?
- jira_request do
+ path = API_ENDPOINTS[:link_remote_issue] % issue.id
+
+ jira_request(path) do
remote_link = find_remote_link(issue, remote_link_props[:object][:url])
create_issue_comment(issue, message) unless remote_link
remote_link ||= issue.remotelink.build
remote_link.save!(remote_link_props)
- log_info("Successfully posted", client_url: client_url)
+ log_info("Successfully posted", client_url: client_url, client_path: path)
"SUCCESS: Successfully posted to #{client_url}."
end
end
@@ -545,7 +559,8 @@ module Integrations
end
def find_remote_link(issue, url)
- links = jira_request { issue.remotelink.all }
+ path = API_ENDPOINTS[:link_remote_issue] % issue.id
+ links = jira_request(path) { issue.remotelink.all }
return unless links
links.find { |link| link.object["url"] == url }
@@ -612,11 +627,11 @@ module Integrations
end
# Handle errors when doing Jira API calls
- def jira_request
+ def jira_request(path)
yield
rescue StandardError => e
@error = e
- log_exception(e, message: 'Error sending message', client_url: client_url)
+ log_exception(e, message: 'Error sending message', client_url: client_url, client_path: path, client_status: e.try(:code))
nil
end
diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb
new file mode 100644
index 00000000000..9af12c712c6
--- /dev/null
+++ b/app/models/integrations/telegram.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Telegram < BaseChatNotification
+ TELEGRAM_HOSTNAME = "https://api.telegram.org/bot%{token}/sendMessage"
+
+ field :token,
+ section: SECTION_TYPE_CONNECTION,
+ help: -> { s_('TelegramIntegration|Unique authentication token.') },
+ non_empty_password_title: -> { s_('TelegramIntegration|New token') },
+ non_empty_password_help: -> { s_('TelegramIntegration|Leave blank to use your current token.') },
+ placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
+ exposes_secrets: true,
+ is_secret: true,
+ required: true
+
+ field :room,
+ title: 'Channel identifier',
+ section: SECTION_TYPE_CONFIGURATION,
+ help: "Unique identifier for the target chat or the username of the target channel (format: @channelusername)",
+ placeholder: '@channelusername',
+ required: true
+
+ with_options if: :activated? do
+ validates :token, :room, presence: true
+ end
+
+ before_validation :set_webhook
+
+ def title
+ 'Telegram'
+ end
+
+ def description
+ s_("TelegramIntegration|Send notifications about project events to Telegram.")
+ end
+
+ def self.to_param
+ 'telegram'
+ end
+
+ def help
+ docs_link = ActionController::Base.helpers.link_to(
+ _('Learn more.'),
+ Rails.application.routes.url_helpers.help_page_url('user/project/integrations/telegram'),
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+ format(s_("TelegramIntegration|Send notifications about project events to Telegram. %{docs_link}"),
+ docs_link: docs_link.html_safe
+ )
+ end
+
+ def fields
+ self.class.fields + build_event_channels
+ end
+
+ def self.supported_events
+ super - ['deployment']
+ end
+
+ def sections
+ [
+ {
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Connection details'),
+ description: help
+ },
+ {
+ type: SECTION_TYPE_TRIGGER,
+ title: s_('Integrations|Trigger'),
+ description: s_('Integrations|An event will be triggered when one of the following items happen.')
+ },
+ {
+ type: SECTION_TYPE_CONFIGURATION,
+ title: s_('Integrations|Notification settings'),
+ description: s_('Integrations|Configure the scope of notifications.')
+ }
+ ]
+ end
+
+ private
+
+ def set_webhook
+ self.webhook = format(TELEGRAM_HOSTNAME, token: token) if token.present?
+ end
+
+ def notify(message, _opts)
+ body = {
+ text: message.summary,
+ chat_id: room,
+ parse_mode: 'markdown'
+ }
+
+ header = { 'Content-Type' => 'application/json' }
+ response = Gitlab::HTTP.post(webhook, headers: header, body: Gitlab::Json.dump(body))
+
+ response if response.success?
+ end
+
+ def custom_data(data)
+ super(data).merge(markdown: true)
+ end
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index f214bc0f1af..890af8a27a0 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -39,7 +39,6 @@ class Issue < ApplicationRecord
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
IssueTypeOutOfSyncError = Class.new(StandardError)
- ForbiddenColumnUsed = Class.new(StandardError)
SORTING_PREFERENCE_FIELD = :issues_sort
MAX_BRANCH_TEMPLATE = 255
@@ -138,28 +137,8 @@ class Issue < ApplicationRecord
validate :issue_type_attribute_present
enum issue_type: WorkItems::Type.base_types
-
# TODO: Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/402699
- WorkItems::Type.base_types.each do |base_type, _value|
- define_method "#{base_type}?".to_sym do
- error_message = <<~ERROR
- `#{base_type}?` uses the `issue_type` column underneath. As we want to remove the column,
- its usage is forbidden. You should use the `work_item_types` table instead.
-
- # Before
-
- issue.requirement? => true
-
- # After
-
- issue.work_item_type.requirement? => true
-
- More details in https://gitlab.com/groups/gitlab-org/-/epics/10529
- ERROR
-
- raise ForbiddenColumnUsed, error_message
- end
- end
+ include ::Issues::ForbidIssueTypeColumnUsage
alias_method :issuing_parent, :project
alias_attribute :issuing_parent_id, :project_id
@@ -219,8 +198,28 @@ class Issue < ApplicationRecord
project: [:project_namespace, :project_feature, :route, { group: :route }, { namespace: :route }],
duplicated_to: { project: [:project_feature] })
}
- scope :with_issue_type, ->(types) { where(issue_type: types) }
- scope :without_issue_type, ->(types) { where.not(issue_type: types) }
+ scope :with_issue_type, ->(types) {
+ types = Array(types)
+
+ if Feature.enabled?(:issue_type_uses_work_item_types_table)
+ # Using != 1 since we also want the guard clause to handle empty arrays
+ return joins(:work_item_type).where(work_item_types: { base_type: types }) if types.size != 1
+
+ where(
+ '"issues"."work_item_type_id" = (?)',
+ WorkItems::Type.by_type(types.first).select(:id).limit(1)
+ )
+ else
+ where(issue_type: types)
+ end
+ }
+ scope :without_issue_type, ->(types) {
+ if Feature.enabled?(:issue_type_uses_work_item_types_table)
+ joins(:work_item_type).where.not(work_item_types: { base_type: types })
+ else
+ where.not(issue_type: types)
+ end
+ }
scope :public_only, -> { where(confidential: false) }
@@ -601,6 +600,10 @@ class Issue < ApplicationRecord
spammable_attribute_changed?
end
+ def supports_recaptcha?
+ true
+ end
+
def as_json(options = {})
super(options).tap do |json|
if options.key?(:labels)
@@ -757,6 +760,12 @@ class Issue < ApplicationRecord
end
end
+ def unsubscribe_email_participant(email)
+ return if email.blank?
+
+ issue_email_participants.find_by_email(email)&.destroy
+ end
+
private
def check_issue_type_in_sync!
@@ -826,11 +835,9 @@ class Issue < ApplicationRecord
end
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)
+ # 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.
+ super || confidential_changed?(from: true, to: false)
end
def ensure_metrics!
diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb
index 1bd34aa0083..af55a5dec91 100644
--- a/app/models/issue_link.rb
+++ b/app/models/issue_link.rb
@@ -9,6 +9,9 @@ class IssueLink < ApplicationRecord
scope :for_source_issue, ->(issue) { where(source_id: issue.id) }
scope :for_target_issue, ->(issue) { where(target_id: issue.id) }
+ scope :for_issues, ->(source, target) do
+ where(source: source, target: target).or(where(source: target, target: source))
+ end
class << self
def issuable_type
diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb
index bb13b83d3ba..ad0df0dca78 100644
--- a/app/models/issue_user_mention.rb
+++ b/app/models/issue_user_mention.rb
@@ -5,5 +5,5 @@ class IssueUserMention < UserMention
belongs_to :note
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
end
diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb
index f07f979a06d..9122f46d92c 100644
--- a/app/models/jira_connect_installation.rb
+++ b/app/models/jira_connect_installation.rb
@@ -4,9 +4,9 @@ class JiraConnectInstallation < ApplicationRecord
include Gitlab::Routing
attr_encrypted :shared_secret,
- mode: :per_attribute_iv,
- algorithm: 'aes-256-gcm',
- key: Settings.attr_encrypted_db_key_base_32
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32
has_many :subscriptions, class_name: 'JiraConnectSubscription'
diff --git a/app/models/key.rb b/app/models/key.rb
index 2ea71bfcd6d..fdc54c9f56e 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -182,7 +182,7 @@ class Key < ApplicationRecord
def forbidden_key_type_message
allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase)
- "type is forbidden. Must be #{Gitlab::Utils.to_exclusive_sentence(allowed_types)}"
+ "type is forbidden. Must be #{Gitlab::Sentence.to_exclusive_sentence(allowed_types)}"
end
def expiration
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 2619a7cca99..b15f32cb356 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -24,8 +24,10 @@ class LfsObject < ApplicationRecord
end
def self.not_linked_to_project(project)
- where('NOT EXISTS (?)',
- project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id'))
+ where(
+ 'NOT EXISTS (?)',
+ project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')
+ )
end
def project_allowed_access?(project)
@@ -44,8 +46,10 @@ class LfsObject < ApplicationRecord
def self.unreferenced_in_batches
each_batch(of: BATCH_SIZE, order: :desc) do |lfs_objects|
- relation = lfs_objects.where('NOT EXISTS (?)',
- LfsObjectsProject.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id'))
+ relation = lfs_objects.where(
+ 'NOT EXISTS (?)',
+ LfsObjectsProject.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')
+ )
yield relation if relation.any?
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 529666a069c..0700b1a8448 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -168,6 +168,7 @@ class Member < ApplicationRecord
scope :non_guests, -> { where('members.access_level > ?', GUEST) }
scope :non_minimal_access, -> { where('members.access_level > ?', MINIMAL_ACCESS) }
scope :owners, -> { active.where(access_level: OWNER) }
+ scope :all_owners, -> { where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :with_user, -> (user) { where(user: user) }
scope :by_access_level, -> (access_level) { active.where(access_level: access_level) }
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index aabc902fe03..237054587bc 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -25,7 +25,7 @@ class GroupMember < Member
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
- attr_accessor :last_owner, :last_blocked_owner
+ attr_accessor :last_owner
# For those who get to see a modal with a role dropdown, here are the options presented
def self.permissible_access_level_roles(_, _)
@@ -52,8 +52,11 @@ class GroupMember < Member
def last_owner_of_the_group?
return false unless access_level == Gitlab::Access::OWNER
+ return last_owner unless last_owner.nil?
- group.member_last_owner?(self) || group.member_last_blocked_owner?(self)
+ group.member_owners_excluding_project_bots.where.not(
+ group: group, user_id: user_id
+ ).empty?
end
private
diff --git a/app/models/members/last_group_owner_assigner.rb b/app/models/members/last_group_owner_assigner.rb
index 48c9bcb9a70..45cd8d8b000 100644
--- a/app/models/members/last_group_owner_assigner.rb
+++ b/app/models/members/last_group_owner_assigner.rb
@@ -8,7 +8,6 @@ class LastGroupOwnerAssigner
end
def execute
- @last_blocked_owner = no_owners_in_hierarchy? && group.single_blocked_owner?
@group_single_owner = owners.size == 1
members.each { |member| set_last_owner(member) }
@@ -16,25 +15,16 @@ class LastGroupOwnerAssigner
private
- attr_reader :group, :members, :last_blocked_owner, :group_single_owner
-
- def no_owners_in_hierarchy?
- owners.empty?
- end
+ attr_reader :group, :members, :group_single_owner
def set_last_owner(member)
- member.last_owner = member.id.in?(owner_ids) && group_single_owner
- member.last_blocked_owner = member.id.in?(blocked_owner_ids) && last_blocked_owner
+ member.last_owner = group_single_owner && member.id.in?(owner_ids)
end
def owner_ids
@owner_ids ||= owners.where(id: member_ids).ids
end
- def blocked_owner_ids
- @blocked_owner_ids ||= group.blocked_owners.where(id: member_ids).ids
- end
-
def member_ids
@members_ids ||= members.pluck(:id)
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7b1d4b97d3b..116108ceaf9 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -23,6 +23,7 @@ class MergeRequest < ApplicationRecord
include Approvable
include IdInOrdered
include Todoable
+ include Spammable
extend ::Gitlab::Utils::Override
@@ -95,9 +96,9 @@ class MergeRequest < ApplicationRecord
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue
- has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline'
+ has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline', inverse_of: :merge_request
has_many :suggestions, through: :notes
- has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note'
+ has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note', inverse_of: :noteable
has_many :merge_request_assignees
has_many :assignees, class_name: "User", through: :merge_request_assignees
@@ -154,6 +155,9 @@ class MergeRequest < ApplicationRecord
# Flag to skip triggering mergeRequestMergeStatusUpdated GraphQL subscription.
attr_accessor :skip_merge_status_trigger
+ attr_spammable :title, spam_title: true
+ attr_spammable :description, spam_description: true
+
participant :reviewers
# Keep states definition to be evaluated before the state_machine block to
@@ -307,6 +311,13 @@ class MergeRequest < ApplicationRecord
scope :open_and_closed, -> { with_states(:opened, :closed) }
scope :drafts, -> { where(draft: true) }
scope :from_source_branches, ->(branches) { where(source_branch: branches) }
+ scope :by_sorted_source_branches, ->(branches) do
+ from_source_branches(branches)
+ .order(source_branch: :asc, id: :desc)
+ end
+ scope :including_target_project, -> do
+ includes(:target_project)
+ end
scope :by_commit_sha, ->(sha) do
where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil)
end
@@ -420,7 +431,7 @@ class MergeRequest < ApplicationRecord
includes(:metrics)
end
- scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) }
+ scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex(expression_escape: '\m').source) }
scope :review_requested, -> do
where(reviewers_subquery.exists)
@@ -2044,6 +2055,10 @@ class MergeRequest < ApplicationRecord
NewMergeRequestWorker.perform_async(id, author_id)
end
+ def check_for_spam?(*)
+ spammable_attribute_changed? && project.public?
+ end
+
private
attr_accessor :skip_fetch_ref
diff --git a/app/models/merge_request/diff_llm_summary.rb b/app/models/merge_request/diff_llm_summary.rb
index 5e7d80712e2..e13fe5e1f50 100644
--- a/app/models/merge_request/diff_llm_summary.rb
+++ b/app/models/merge_request/diff_llm_summary.rb
@@ -5,6 +5,7 @@ class MergeRequest::DiffLlmSummary < ApplicationRecord
belongs_to :merge_request_diff
belongs_to :user, optional: true
+ validates :merge_request_diff_id, uniqueness: true
validates :provider, presence: true
validates :content, presence: true, length: { maximum: 2056 }
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 0e699d7a81d..33930836c48 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -32,7 +32,7 @@ class MergeRequestDiff < ApplicationRecord
-> { order(:merge_request_diff_id, :relative_order) },
inverse_of: :merge_request_diff
- has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }, inverse_of: :merge_request_diff
validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true
validates :merge_request_id, uniqueness: { scope: :diff_type }, if: :merge_head?
@@ -592,8 +592,8 @@ class MergeRequestDiff < ApplicationRecord
end
def remove_cached_external_diff
- Gitlab::Utils.check_path_traversal!(external_diff_cache_dir)
- Gitlab::Utils.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir])
+ Gitlab::PathTraversal.check_path_traversal!(external_diff_cache_dir)
+ Gitlab::PathTraversal.check_allowed_absolute_path!(external_diff_cache_dir, [Dir.tmpdir])
return unless Dir.exist?(external_diff_cache_dir)
diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb
index d946fd14628..3157f1ca2aa 100644
--- a/app/models/merge_request_user_mention.rb
+++ b/app/models/merge_request_user_mention.rb
@@ -3,7 +3,7 @@
class MergeRequestUserMention < UserMention
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :merge_request
belongs_to :note
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 7c6fa24cd4d..7b3bb04da5b 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -229,11 +229,15 @@ class Namespace < ApplicationRecord
# query - The search query as a String.
#
# Returns an ActiveRecord::Relation.
- def search(query, include_parents: false)
+ def search(query, include_parents: false, use_minimum_char_limit: true)
if include_parents
- without_project_namespaces.where(id: Route.for_routable_type(Namespace.name).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]]).select(:source_id))
+ without_project_namespaces
+ .where(id: Route.for_routable_type(Namespace.name)
+ .fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name]],
+ use_minimum_char_limit: use_minimum_char_limit)
+ .select(:source_id))
else
- without_project_namespaces.fuzzy_search(query, [:path, :name])
+ without_project_namespaces.fuzzy_search(query, [:path, :name], use_minimum_char_limit: use_minimum_char_limit)
end
end
@@ -400,6 +404,12 @@ class Namespace < ApplicationRecord
Project.where(namespace: namespace)
end
+ # Includes projects from this namespace and projects from all subgroups
+ # that belongs to this namespace, except the ones that are soft deleted
+ def all_projects_except_soft_deleted
+ all_projects.not_aimed_for_deletion
+ end
+
def has_parent?
parent_id.present? || parent.present?
end
diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb
index e08c08f9ced..6c977505f17 100644
--- a/app/models/namespace/aggregation_schedule.rb
+++ b/app/models/namespace/aggregation_schedule.rb
@@ -14,7 +14,7 @@ class Namespace::AggregationSchedule < ApplicationRecord
def default_lease_timeout
if Feature.enabled?(:reduce_aggregation_schedule_lease, namespace.root_ancestor)
- 2.minutes.to_i
+ ::Gitlab::CurrentSettings.namespace_aggregation_schedule_lease_duration_in_seconds
else
30.minutes.to_i
end
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
index 0443e1d9231..8af0cf2767c 100644
--- a/app/models/namespace/root_storage_statistics.rb
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -21,7 +21,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) }
- delegate :all_projects, to: :namespace
+ delegate :all_projects_except_soft_deleted, to: :namespace
enum notification_level: {
storage_remaining: 100,
@@ -60,8 +60,6 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def attributes_for_forks_statistics
- return {} unless ::Feature.enabled?(:root_storage_statistics_calculate_forks, namespace)
-
visibility_levels_to_storage_size_columns = {
Gitlab::VisibilityLevel::PRIVATE => :private_forks_storage_size,
Gitlab::VisibilityLevel::INTERNAL => :internal_forks_storage_size,
@@ -78,7 +76,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def for_forks_statistics
- all_projects
+ all_projects_except_soft_deleted
.joins([:statistics, :fork_network])
.where('fork_networks.root_project_id != projects.id')
.group('projects.visibility_level')
@@ -94,7 +92,7 @@ class Namespace::RootStorageStatistics < ApplicationRecord
end
def from_project_statistics
- all_projects
+ all_projects_except_soft_deleted
.joins('INNER JOIN project_statistics ps ON ps.project_id = projects.id')
.select(
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index e7f6db38047..5b114bb42aa 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -12,8 +12,12 @@ class NamespaceSetting < ApplicationRecord
enum jobs_to_be_done: { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5 }, _suffix: true
enum enabled_git_access_protocol: { all: 0, ssh: 1, http: 2 }, _suffix: true
+ attribute :default_branch_protection_defaults, default: -> { {} }
+
validates :enabled_git_access_protocol, inclusion: { in: enabled_git_access_protocols.keys }
validates :code_suggestions, allow_nil: false, inclusion: { in: [true, false] }
+ validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' }
+ validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } }
validate :allow_mfa_for_group
validate :allow_resource_access_token_creation_for_group
@@ -22,6 +26,8 @@ class NamespaceSetting < ApplicationRecord
before_validation :normalize_default_branch_name
+ after_create :set_code_suggestions_default
+
chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval
chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval
chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval
@@ -41,6 +47,9 @@ class NamespaceSetting < ApplicationRecord
project_runner_token_expiration_interval
].freeze
+ # matches the size set in the database constraint
+ DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE = 1.kilobyte
+
self.primary_key = :namespace_id
def self.allowed_namespace_settings_params
@@ -87,6 +96,14 @@ class NamespaceSetting < ApplicationRecord
self.default_branch_name = default_branch_name.presence
end
+ def set_code_suggestions_default
+ # users should have code suggestions disabled by default
+ return if namespace&.user_namespace?
+
+ # groups should have code suggestions enabled by default
+ update_column(:code_suggestions, true)
+ end
+
def allow_mfa_for_group
if namespace&.subgroup? && allow_mfa_for_subgroups == false
errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.'))
diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb
index cf2612b7f33..bf23fc21124 100644
--- a/app/models/namespaces/project_namespace.rb
+++ b/app/models/namespaces/project_namespace.rb
@@ -11,7 +11,8 @@ module Namespaces
alias_attribute :namespace_id, :parent_id
has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace
- delegate :execute_hooks, :execute_integrations, to: :project, allow_nil: true
+ delegate :execute_hooks, :execute_integrations, :group, to: :project, allow_nil: true
+ delegate :external_references_supported?, :default_issues_tracker?, to: :project
def self.sti_name
'Project'
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 792964a6c7f..c50d3dd1de6 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -25,8 +25,6 @@ module Namespaces
end
def self_and_ancestors(include_self: true, upto: nil, hierarchy_order: nil)
- return super unless use_traversal_ids_for_ancestor_scopes?
-
self_and_ancestors_from_inner_join(
include_self: include_self,
upto: upto, hierarchy_order:
@@ -35,8 +33,6 @@ module Namespaces
end
def self_and_ancestor_ids(include_self: true)
- return super unless use_traversal_ids_for_ancestor_scopes?
-
self_and_ancestors(include_self: include_self).as_ids
end
@@ -87,11 +83,6 @@ module Namespaces
use_traversal_ids?
end
- def use_traversal_ids_for_ancestor_scopes?
- Feature.enabled?(:use_traversal_ids_for_ancestor_scopes) &&
- use_traversal_ids?
- end
-
def use_traversal_ids_for_descendants_scopes?
Feature.enabled?(:use_traversal_ids_for_descendants_scopes) &&
use_traversal_ids?
diff --git a/app/models/note.rb b/app/models/note.rb
index ac2b54629ae..09ff7ad3979 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -24,6 +24,7 @@ class Note < ApplicationRecord
include Sortable
include EachBatch
include IgnorableColumns
+ include Spammable
ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
@@ -68,6 +69,8 @@ class Note < ApplicationRecord
attribute :system, default: false
+ attr_spammable :note, spam_description: true
+
attr_mentionable :note, pipeline: :note
participant :author
@@ -141,6 +144,7 @@ class Note < ApplicationRecord
scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) }
scope :with_suggestions, -> { joins(:suggestions) }
scope :inc_author, -> { includes(:author) }
+ scope :authored_by, ->(user) { where(author: user) }
scope :inc_note_diff_file, -> { includes(:note_diff_file) }
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, ->(noteable = nil) do
@@ -429,6 +433,10 @@ class Note < ApplicationRecord
project&.team&.contributor?(self.author_id)
end
+ def human_max_access
+ project&.team&.human_max_access(self.author_id)
+ end
+
def noteable_author?(noteable)
noteable.author == self.author
end
@@ -688,6 +696,7 @@ class Note < ApplicationRecord
def show_outdated_changes?
return false unless for_merge_request?
return false unless system?
+ return false if change_position&.on_file?
return false unless change_position&.line_range
change_position.line_range["end"] || change_position.line_range["start"]
@@ -773,6 +782,16 @@ class Note < ApplicationRecord
readable_by?(user)
end
+ # Override method defined in Spammable
+ # Wildcard argument because user: argument is not used
+ def check_for_spam?(*)
+ return false if system? || !spammable_attribute_changed? || confidential?
+ return false if noteable.try(:confidential?) == true || noteable.try(:public?) == false
+ return false if noteable.try(:group)&.public? == false || project&.public? == false
+
+ true
+ end
+
private
def trigger_note_subscription?
diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb
index e4936de7b40..b0f6af0d853 100644
--- a/app/models/note_diff_file.rb
+++ b/app/models/note_diff_file.rb
@@ -4,7 +4,7 @@ class NoteDiffFile < ApplicationRecord
include DiffFile
include IgnorableColumns
- ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :diff_note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
scope :referencing_sha, -> (oids, project_id:) do
joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids })
diff --git a/app/models/organization.rb b/app/models/organization.rb
deleted file mode 100644
index cfbbbf1183e..00000000000
--- a/app/models/organization.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-class Organization < ApplicationRecord
- DEFAULT_ORGANIZATION_ID = 1
-
- scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) }
-
- before_destroy :check_if_default_organization
-
- validates :name,
- presence: true,
- length: { maximum: 255 },
- uniqueness: { case_sensitive: false }
-
- def default?
- id == DEFAULT_ORGANIZATION_ID
- end
-
- private
-
- def check_if_default_organization
- return unless default?
-
- raise ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization')
- end
-end
diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb
new file mode 100644
index 00000000000..ce89f57a73b
--- /dev/null
+++ b/app/models/organizations/organization.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Organizations
+ class Organization < ApplicationRecord
+ DEFAULT_ORGANIZATION_ID = 1
+
+ scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) }
+
+ before_destroy :check_if_default_organization
+
+ validates :name,
+ presence: true,
+ length: { maximum: 255 }
+
+ validates :path,
+ presence: true,
+ 'organizations/path': true,
+ length: { minimum: 2, maximum: 255 }
+
+ def self.default_organization
+ find_by(id: DEFAULT_ORGANIZATION_ID)
+ end
+
+ def default?
+ id == DEFAULT_ORGANIZATION_ID
+ end
+
+ def to_param
+ path
+ end
+
+ private
+
+ def check_if_default_organization
+ return unless default?
+
+ raise ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization')
+ end
+ end
+end
diff --git a/app/models/packages/cleanup/policy.rb b/app/models/packages/cleanup/policy.rb
index 35f58f3680d..e8729704192 100644
--- a/app/models/packages/cleanup/policy.rb
+++ b/app/models/packages/cleanup/policy.rb
@@ -12,11 +12,10 @@ module Packages
belongs_to :project
validates :project, presence: true
- validates :keep_n_duplicated_package_files,
- inclusion: {
- in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES,
- message: 'is invalid'
- }
+ validates :keep_n_duplicated_package_files, inclusion: {
+ in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES,
+ message: 'is invalid'
+ }
# used by Schedulable
def self.active
diff --git a/app/models/packages/conan/metadatum.rb b/app/models/packages/conan/metadatum.rb
index 58af34879af..fc46e0b3b10 100644
--- a/app/models/packages/conan/metadatum.rb
+++ b/app/models/packages/conan/metadatum.rb
@@ -8,9 +8,9 @@ class Packages::Conan::Metadatum < ApplicationRecord
validates :package, presence: true
validates :package_username,
- :package_channel,
- presence: true,
- format: { with: Gitlab::Regex.conan_recipe_user_channel_regex }
+ :package_channel,
+ presence: true,
+ format: { with: Gitlab::Regex.conan_recipe_user_channel_regex }
validate :conan_package_type
validate :username_channel_none_values
diff --git a/app/models/packages/debian/file_entry.rb b/app/models/packages/debian/file_entry.rb
index eb66f4acfa9..7ea0dfe8765 100644
--- a/app/models/packages/debian/file_entry.rb
+++ b/app/models/packages/debian/file_entry.rb
@@ -9,13 +9,13 @@ module Packages
FILENAME_REGEX = %r{\A[a-zA-Z0-9][a-zA-Z0-9_.~+-]*\z}.freeze
attr_accessor :filename,
- :size,
- :md5sum,
- :section,
- :priority,
- :sha1sum,
- :sha256sum,
- :package_file
+ :size,
+ :md5sum,
+ :section,
+ :priority,
+ :sha1sum,
+ :sha256sum,
+ :package_file
validates :filename, :size, :md5sum, :section, :priority, :sha1sum, :sha256sum, :package_file, presence: true
validates :filename, format: { with: FILENAME_REGEX }
diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb
index a029437c82d..958658e68c1 100644
--- a/app/models/packages/go/module.rb
+++ b/app/models/packages/go/module.rb
@@ -14,8 +14,9 @@ module Packages
end
def versions
- strong_memoize(:versions) { Packages::Go::VersionFinder.new(self).execute }
+ Packages::Go::VersionFinder.new(self).execute
end
+ strong_memoize_attr :versions
def version_by(ref: nil, commit: nil)
raise ArgumentError, 'no filter specified' unless ref || commit
diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb
index 5869a03e081..17b97151f29 100644
--- a/app/models/packages/go/module_version.rb
+++ b/app/models/packages/go/module_version.rb
@@ -46,16 +46,15 @@ module Packages
end
def gomod
- strong_memoize(:gomod) do
- if strong_memoized?(:blobs)
- blob_at(@mod.path + '/go.mod')
- elsif @mod.path.empty?
- @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data
- else
- @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data
- end
+ if strong_memoized?(:blobs)
+ blob_at(@mod.path + '/go.mod')
+ elsif @mod.path.empty?
+ @mod.project.repository.blob_at(@commit.sha, 'go.mod')&.data
+ else
+ @mod.project.repository.blob_at(@commit.sha, @mod.path + '/go.mod')&.data
end
end
+ strong_memoize_attr :gomod
def archive
suffix_len = @mod.path == '' ? 0 : @mod.path.length + 1
@@ -69,18 +68,16 @@ module Packages
end
def files
- strong_memoize(:files) do
- ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } }
- end
+ ls_tree.filter { |e| !excluded.any? { |n| e.start_with? n } }
end
+ strong_memoize_attr :files
def excluded
- strong_memoize(:excluded) do
- ls_tree
+ ls_tree
.filter { |f| f.end_with?('/go.mod') && f != @mod.path + '/go.mod' }
.map { |f| f[0..-7] }
- end
end
+ strong_memoize_attr :excluded
def valid?
# assume the module version is valid if a corresponding Package exists
@@ -100,21 +97,20 @@ module Packages
end
def blobs
- strong_memoize(:blobs) { @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] }) }
+ @mod.project.repository.batch_blobs(files.map { |x| [@commit.sha, x] })
end
+ strong_memoize_attr :blobs
def ls_tree
- strong_memoize(:ls_tree) do
- path =
- if @mod.path.empty?
- '.'
- else
- @mod.path
- end
-
- @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path)
- end
+ path = if @mod.path.empty?
+ '.'
+ else
+ @mod.path
+ end
+
+ @mod.project.repository.gitaly_repository_client.search_files_by_name(@commit.sha, path)
end
+ strong_memoize_attr :ls_tree
end
end
end
diff --git a/app/models/packages/npm/metadata_cache.rb b/app/models/packages/npm/metadata_cache.rb
index 7a7c66d7a45..02efeda69cb 100644
--- a/app/models/packages/npm/metadata_cache.rb
+++ b/app/models/packages/npm/metadata_cache.rb
@@ -4,6 +4,7 @@ module Packages
module Npm
class MetadataCache < ApplicationRecord
include FileStoreMounter
+ include Packages::Downloadable
belongs_to :project, inverse_of: :npm_metadata_caches
diff --git a/app/models/packages/nuget/metadatum.rb b/app/models/packages/nuget/metadatum.rb
index 1db8c0eddbf..fae7728cccb 100644
--- a/app/models/packages/nuget/metadatum.rb
+++ b/app/models/packages/nuget/metadatum.rb
@@ -1,24 +1,23 @@
# frozen_string_literal: true
class Packages::Nuget::Metadatum < ApplicationRecord
+ MAX_AUTHORS_LENGTH = 255
+ MAX_DESCRIPTION_LENGTH = 4000
+ MAX_URL_LENGTH = 255
+
belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_metadatum
validates :package, presence: true
- validates :license_url, public_url: { allow_blank: true }
- validates :project_url, public_url: { allow_blank: true }
- validates :icon_url, public_url: { allow_blank: true }
+ validates :license_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH }
+ validates :project_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH }
+ validates :icon_url, public_url: { allow_blank: true }, length: { maximum: MAX_URL_LENGTH }
+ validates :authors, presence: true, length: { maximum: MAX_AUTHORS_LENGTH }
+ validates :description, presence: true, length: { maximum: MAX_DESCRIPTION_LENGTH }
- validate :ensure_at_least_one_field_supplied
validate :ensure_nuget_package_type
private
- def ensure_at_least_one_field_supplied
- return if license_url? || project_url? || icon_url?
-
- errors.add(:base, _('Nuget metadatum must have at least license_url, project_url or icon_url set'))
- end
-
def ensure_nuget_package_type
return if package&.nuget?
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index c58ad92d7a6..58305b45457 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -6,6 +6,7 @@ class Packages::Package < ApplicationRecord
include UsageStatistics
include Gitlab::Utils::StrongMemoize
include Packages::Installable
+ include Packages::Downloadable
DISPLAYABLE_STATUSES = [:default, :error].freeze
INSTALLABLE_STATUSES = [:default, :hidden].freeze
@@ -23,7 +24,8 @@ class Packages::Package < ApplicationRecord
rubygems: 10,
helm: 11,
terraform_module: 12,
- rpm: 13
+ rpm: 13,
+ ml_model: 14
}
enum status: { default: 0, hidden: 1, processing: 2, error: 3, pending_destruction: 4 }
@@ -68,11 +70,11 @@ class Packages::Package < ApplicationRecord
validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? || debian? }
validates :name,
- uniqueness: {
- scope: %i[project_id version package_type],
- conditions: -> { not_pending_destruction }
- },
- unless: -> { pending_destruction? || conan? }
+ uniqueness: {
+ scope: %i[project_id version package_type],
+ conditions: -> { not_pending_destruction }
+ },
+ unless: -> { pending_destruction? || conan? }
validate :valid_conan_package_recipe, if: :conan?
validate :valid_composer_global_name, if: :composer?
@@ -225,6 +227,10 @@ class Packages::Package < ApplicationRecord
find_by!(name: name, version: version)
end
+ def self.debian_incoming_package!
+ find_by!(name: Packages::Debian::INCOMING_PACKAGE_NAME, version: nil, package_type: :debian, status: :default)
+ end
+
def self.existing_debian_packages_with(name:, version:)
debian.with_name(name)
.with_version(version)
@@ -297,20 +303,14 @@ class Packages::Package < ApplicationRecord
end
# Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937
- # TODO: rename the method https://gitlab.com/gitlab-org/gitlab/-/issues/410352
- def original_build_info
- strong_memoize(:original_build_info) do
- if Feature.enabled?(:packages_display_last_pipeline, project)
- build_infos.last
- else
- build_infos.first
- end
- end
+ def last_build_info
+ build_infos.last
end
+ strong_memoize_attr :last_build_info
# Technical debt: to be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/281937
def pipeline
- original_build_info&.pipeline
+ last_build_info&.pipeline
end
def tag_names
@@ -330,10 +330,9 @@ class Packages::Package < ApplicationRecord
end
def package_settings
- strong_memoize(:package_settings) do
- project.namespace.package_settings
- end
+ project.namespace.package_settings
end
+ strong_memoize_attr :package_settings
def sync_maven_metadata(user)
return unless maven? && version? && user
@@ -361,12 +360,6 @@ class Packages::Package < ApplicationRecord
name.gsub(/#{Gitlab::Regex::Packages::PYPI_NORMALIZED_NAME_REGEX_STRING}/o, '-').downcase
end
- def touch_last_downloaded_at
- ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
- update_column(:last_downloaded_at, Time.zone.now)
- end
- end
-
def publish_creation_event
::Gitlab::EventStore.publish(
::Packages::PackageCreatedEvent.new(data: {
@@ -439,5 +432,3 @@ class Packages::Package < ApplicationRecord
end
end
end
-
-Packages::Package.prepend_mod
diff --git a/app/models/packages/rpm/metadatum.rb b/app/models/packages/rpm/metadatum.rb
index 07361995a12..7ee9e2d0064 100644
--- a/app/models/packages/rpm/metadatum.rb
+++ b/app/models/packages/rpm/metadatum.rb
@@ -8,34 +8,13 @@ module Packages
belongs_to :package, -> { where(package_type: :rpm) }, inverse_of: :rpm_metadatum
validates :package, presence: true
-
- validates :epoch,
- presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
-
- validates :release,
- presence: true,
- length: { maximum: 128 }
-
- validates :summary,
- presence: true,
- length: { maximum: 1000 }
-
- validates :description,
- presence: true,
- length: { maximum: 5000 }
-
- validates :arch,
- presence: true,
- length: { maximum: 255 }
-
- validates :license,
- allow_nil: true,
- length: { maximum: 1000 }
-
- validates :url,
- allow_nil: true,
- length: { maximum: 1000 }
+ validates :epoch, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :release, presence: true, length: { maximum: 128 }
+ validates :summary, presence: true, length: { maximum: 1000 }
+ validates :description, presence: true, length: { maximum: 5000 }
+ validates :arch, presence: true, length: { maximum: 255 }
+ validates :license, allow_nil: true, length: { maximum: 1000 }
+ validates :url, allow_nil: true, length: { maximum: 1000 }
validate :rpm_package_type
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 10ac10295fc..88d7f0f972a 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -23,10 +23,10 @@ class PagesDomain < ApplicationRecord
validates :domain, uniqueness: { case_sensitive: false }
validates :certificate, :key, presence: true, if: :usage_serverless?
validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' },
- if: :certificate_should_be_present?
+ if: :certificate_should_be_present?
validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? }
validates :key, presence: { message: 'must be present if HTTPS-only is enabled' },
- if: :certificate_should_be_present?
+ if: :certificate_should_be_present?
validates :key, certificate_key: true, named_ecdsa_key: true, if: ->(domain) { domain.key.present? }
validates :verification_code, presence: true, allow_blank: false
diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb
index 8427176fa72..80688e8d247 100644
--- a/app/models/pages_domain_acme_order.rb
+++ b/app/models/pages_domain_acme_order.rb
@@ -13,10 +13,10 @@ class PagesDomainAcmeOrder < ApplicationRecord
validates :private_key, presence: true
attr_encrypted :private_key,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- encode: true
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: true
def self.find_by_domain_and_token(domain_name, challenge_token)
joins(:pages_domain).find_by(pages_domains: { domain: domain_name }, challenge_token: challenge_token)
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 75afff6a2fa..2749404b7b5 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -24,11 +24,6 @@ class PersonalAccessToken < ApplicationRecord
after_initialize :set_default_scopes, if: :persisted?
before_save :ensure_token
- # During the implementation of Admin Mode for API, tokens of
- # administrators should automatically get the `admin_mode` scope as well
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/42692
- before_create :add_admin_mode_scope, if: -> { Feature.disabled?(:admin_mode_for_api) && user_admin? }
-
scope :active, -> { not_revoked.not_expired }
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
@@ -49,6 +44,7 @@ class PersonalAccessToken < ApplicationRecord
validates :scopes, presence: true
validate :validate_scopes
+ validates :expires_at, presence: true, on: :create
validate :expires_at_before_instance_max_expiry_date, on: :create
def revoke!
@@ -59,19 +55,6 @@ class PersonalAccessToken < ApplicationRecord
!revoked? && !expired?
end
- # fall back to default value until background migration has updated all
- # existing PATs and we can add a validation
- # https://gitlab.com/gitlab-org/gitlab/-/issues/369123
- def expires_at=(value)
- datetime = if Feature.enabled?(:default_pat_expiration)
- value.presence || MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
- else
- value
- end
-
- super(datetime)
- end
-
override :simple_sorts
def self.simple_sorts
super.merge(
@@ -89,17 +72,10 @@ class PersonalAccessToken < ApplicationRecord
fuzzy_search(query, [:name])
end
- def project_access_token?
- user&.project_bot?
- end
-
protected
def validate_scopes
- valid_scopes = Gitlab::Auth.all_available_scopes
- valid_scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE] if Feature.disabled?(:admin_mode_for_api)
-
- unless revoked || scopes.all? { |scope| valid_scopes.include?(scope.to_sym) }
+ unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
@@ -116,16 +92,11 @@ class PersonalAccessToken < ApplicationRecord
user.admin? # rubocop: disable Cop/UserAdmin
end
- def add_admin_mode_scope
- self.scopes += [Gitlab::Auth::ADMIN_MODE_SCOPE.to_s]
- end
-
def prefix_from_application_current_settings
self.class.token_prefix
end
def expires_at_before_instance_max_expiry_date
- return unless Feature.enabled?(:default_pat_expiration)
return unless expires_at
if expires_at > MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
index bf69f425189..6795e7a3049 100644
--- a/app/models/plan_limits.rb
+++ b/app/models/plan_limits.rb
@@ -4,11 +4,18 @@ class PlanLimits < ApplicationRecord
include IgnorableColumns
ignore_column :ci_max_artifact_size_running_container_scanning, remove_with: '14.3', remove_after: '2021-08-22'
ignore_column :web_hook_calls_high, remove_with: '15.10', remove_after: '2022-02-22'
+ ignore_column :ci_active_pipelines, remove_with: '16.3', remove_after: '2022-07-22'
+
+ attribute :limits_history, :ind_jsonb, default: -> { {} }
+ validates :limits_history, json_schema: { filename: 'plan_limits_history' }
LimitUndefinedError = Class.new(StandardError)
belongs_to :plan
+ validates :notification_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :enforcement_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
def exceeded?(limit_name, subject, alternate_limit: 0)
limit = limit_for(limit_name, alternate_limit: alternate_limit)
return false unless limit
@@ -37,4 +44,39 @@ class PlanLimits < ApplicationRecord
limits = [limit, alternate_limit]
limits.map(&:to_i).select(&:positive?).min
end
+
+ # Overridden in EE
+ def dashboard_storage_limit_enabled?
+ false
+ end
+
+ def log_limits_changes(user, new_limits)
+ new_limits.each do |attribute, value|
+ limits_history[attribute] ||= []
+ limits_history[attribute] << {
+ user_id: user&.id,
+ username: user&.username,
+ timestamp: Time.current.utc.to_i,
+ value: value
+ }
+ end
+
+ update(limits_history: limits_history)
+ end
+
+ def limit_attribute_changes(attribute)
+ limit_history = limits_history[attribute]
+ return [] unless limit_history
+
+ limit_history.map do |entry|
+ {
+ timestamp: entry[:timestamp],
+ value: entry[:value],
+ username: entry[:username],
+ user_id: entry[:user_id]
+ }
+ end
+ end
end
+
+PlanLimits.prepend_mod_with('PlanLimits')
diff --git a/app/models/preloaders/projects/notes_preloader.rb b/app/models/preloaders/projects/notes_preloader.rb
new file mode 100644
index 00000000000..d3a05951926
--- /dev/null
+++ b/app/models/preloaders/projects/notes_preloader.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Preloaders
+ module Projects
+ class NotesPreloader
+ include RendersNotes
+
+ def initialize(project, current_user)
+ @project = project
+ @current_user = current_user
+ end
+
+ def call(notes)
+ prepare_notes_for_rendering(notes)
+ end
+
+ private
+
+ attr_reader :current_user
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 224193fba08..452a5c8973c 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -111,9 +111,9 @@ class Project < ApplicationRecord
attribute :ci_config_path, default: -> { Gitlab::CurrentSettings.default_ci_config_path }
add_authentication_token_field :runners_token,
- encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption) ? :optional : :required },
- format_with_prefix: :runners_token_prefix,
- require_prefix_for_validation: true
+ encrypted: :required,
+ format_with_prefix: :runners_token_prefix,
+ require_prefix_for_validation: true
# Storage specific hooks
after_initialize :use_hashed_storage
@@ -185,6 +185,7 @@ class Project < ApplicationRecord
has_one :bugzilla_integration, class_name: 'Integrations::Bugzilla'
has_one :buildkite_integration, class_name: 'Integrations::Buildkite'
has_one :campfire_integration, class_name: 'Integrations::Campfire'
+ has_one :clickup_integration, class_name: 'Integrations::Clickup'
has_one :confluence_integration, class_name: 'Integrations::Confluence'
has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker'
has_one :datadog_integration, class_name: 'Integrations::Datadog'
@@ -194,6 +195,7 @@ class Project < ApplicationRecord
has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush'
has_one :ewm_integration, class_name: 'Integrations::Ewm'
has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki'
+ has_one :gitlab_slack_application_integration, class_name: 'Integrations::GitlabSlackApplication'
has_one :google_play_integration, class_name: 'Integrations::GooglePlay'
has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
has_one :harbor_integration, class_name: 'Integrations::Harbor'
@@ -217,6 +219,7 @@ class Project < ApplicationRecord
has_one :slack_slash_commands_integration, class_name: 'Integrations::SlackSlashCommands'
has_one :squash_tm_integration, class_name: 'Integrations::SquashTm'
has_one :teamcity_integration, class_name: 'Integrations::Teamcity'
+ has_one :telegram_integration, class_name: 'Integrations::Telegram'
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'
@@ -224,10 +227,7 @@ class Project < ApplicationRecord
has_one :wiki_repository, class_name: 'Projects::WikiRepository', inverse_of: :project
has_one :design_management_repository, class_name: 'DesignManagement::Repository', inverse_of: :project
- has_one :root_of_fork_network,
- foreign_key: 'root_project_id',
- inverse_of: :root_project,
- class_name: 'ForkNetwork'
+ has_one :root_of_fork_network, foreign_key: 'root_project_id', inverse_of: :root_project, class_name: 'ForkNetwork'
has_one :fork_network_member
has_one :fork_network, through: :fork_network_member
has_one :forked_from_project, through: :fork_network_member
@@ -247,24 +247,19 @@ class Project < ApplicationRecord
has_many :fork_network_projects, through: :fork_network, source: :projects
# Packages
- has_many :packages,
- class_name: 'Packages::Package'
- has_many :package_files,
- through: :packages, class_name: 'Packages::PackageFile'
+ has_many :packages, class_name: 'Packages::Package'
+ has_many :package_files, through: :packages, class_name: 'Packages::PackageFile'
# repository_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :rpm_repository_files,
- inverse_of: :project,
- class_name: 'Packages::Rpm::RepositoryFile',
- dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ inverse_of: :project,
+ class_name: 'Packages::Rpm::RepositoryFile',
+ dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :debian_distributions,
- class_name: 'Packages::Debian::ProjectDistribution',
- dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :npm_metadata_caches,
- class_name: 'Packages::Npm::MetadataCache'
- has_one :packages_cleanup_policy,
- class_name: 'Packages::Cleanup::Policy',
- inverse_of: :project
+ class_name: 'Packages::Debian::ProjectDistribution',
+ dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :npm_metadata_caches, class_name: 'Packages::Npm::MetadataCache'
+ has_one :packages_cleanup_policy, class_name: 'Packages::Cleanup::Policy', inverse_of: :project
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -388,10 +383,7 @@ class Project < ApplicationRecord
# The relation :ci_pipelines includes all those that directly contribute to the
# latest status of a ref. This does not include dangling pipelines such as those
# from webide, child pipelines, etc.
- has_many :ci_pipelines,
- -> { ci_sources },
- class_name: 'Ci::Pipeline',
- inverse_of: :project
+ has_many :ci_pipelines, -> { ci_sources }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project
has_many :pipeline_metadata, class_name: 'Ci::PipelineMetadata', inverse_of: :project
@@ -473,8 +465,8 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :container_expiration_policy, update_only: true
accepts_nested_attributes_for :remote_mirrors,
- allow_destroy: true,
- reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
+ allow_destroy: true,
+ reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
accepts_nested_attributes_for :incident_management_setting, update_only: true
accepts_nested_attributes_for :error_tracking_setting, update_only: true
@@ -483,60 +475,63 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :prometheus_integration, update_only: true
accepts_nested_attributes_for :alerting_setting, update_only: true
- delegate :merge_requests_access_level, :forking_access_level, :issues_access_level,
- :wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level, :package_registry_access_level, :pages_access_level,
- :metrics_dashboard_access_level, :analytics_access_level,
- :operations_access_level, :security_and_compliance_access_level,
- :container_registry_access_level, :environments_access_level, :feature_flags_access_level,
- :monitor_access_level, :releases_access_level, :infrastructure_access_level,
- to: :project_feature, allow_nil: true
-
- delegate :show_default_award_emojis, :show_default_award_emojis=,
- :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=,
- :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=,
- to: :project_setting, allow_nil: true
-
- delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?,
- :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=,
- to: :project_setting
-
- delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?, to: :project_setting
- delegate :squash_option, :squash_option=, to: :project_setting
- delegate :mr_default_target_self, :mr_default_target_self=, to: :project_setting
- delegate :previous_default_branch, :previous_default_branch=, to: :project_setting
+ delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, to: :project_feature, allow_nil: true
delegate :name, to: :owner, allow_nil: true, prefix: true
- delegate :members, to: :team, prefix: true
- delegate :add_member, :add_members, :member?, to: :team
- delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team
- delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true
- delegate :root_ancestor, to: :namespace, allow_nil: true
+ delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
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=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
- delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci_outbound, allow_nil: true
- delegate :inbound_job_token_scope_enabled, :inbound_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 :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
- delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true
- delegate :separated_caches, :separated_caches=, to: :ci_cd_settings, prefix: :ci, allow_nil: true
- delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true
- delegate :actual_limits, :actual_plan_name, :actual_plan, to: :namespace, allow_nil: true
- delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
- :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?,
- to: :project_setting
- delegate :merge_commit_template, :merge_commit_template=, to: :project_setting, allow_nil: true
- delegate :squash_commit_template, :squash_commit_template=, to: :project_setting, allow_nil: true
- delegate :issue_branch_template, :issue_branch_template=, to: :project_setting, allow_nil: true
- delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage
+ with_options to: :team do
+ delegate :members, prefix: true
+ delegate :add_member, :add_members, :member?
+ delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role
+ end
- delegate :maven_package_requests_forwarding,
- :pypi_package_requests_forwarding,
- :npm_package_requests_forwarding,
- to: :namespace
+ with_options to: :metrics_setting, allow_nil: true, prefix: true do
+ delegate :external_dashboard_url
+ delegate :dashboard_timezone
+ end
+
+ with_options to: :namespace do
+ delegate :actual_limits, :actual_plan_name, :actual_plan, :root_ancestor, allow_nil: true
+ delegate :maven_package_requests_forwarding, :pypi_package_requests_forwarding, :npm_package_requests_forwarding
+ end
+
+ with_options to: :ci_cd_settings, allow_nil: true do
+ delegate :group_runners_enabled, :group_runners_enabled=
+ delegate :keep_latest_artifact, :keep_latest_artifact=
+ delegate :restrict_user_defined_variables, :restrict_user_defined_variables=
+ delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=
+ delegate :job_token_scope_enabled, :job_token_scope_enabled=, prefix: :ci_outbound
+
+ with_options prefix: :ci do
+ delegate :default_git_depth, :default_git_depth=
+ delegate :forward_deployment_enabled, :forward_deployment_enabled=
+ delegate :inbound_job_token_scope_enabled, :inbound_job_token_scope_enabled=
+ delegate :allow_fork_pipelines_to_run_in_parent_project, :allow_fork_pipelines_to_run_in_parent_project=
+ delegate :separated_caches, :separated_caches=
+ end
+ end
+
+ with_options to: :project_setting do
+ delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=
+ delegate :has_confluence?
+ delegate :has_shimo?
+ delegate :show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?
+ delegate :runner_registration_enabled, :runner_registration_enabled=, :runner_registration_enabled?
+ delegate :squash_always?, :squash_never?, :squash_enabled_by_default?, :squash_readonly?
+ delegate :mr_default_target_self, :mr_default_target_self=
+ delegate :previous_default_branch, :previous_default_branch=
+ delegate :squash_option, :squash_option=
+
+ with_options allow_nil: true do
+ delegate :merge_commit_template, :merge_commit_template=
+ delegate :squash_commit_template, :squash_commit_template=
+ delegate :issue_branch_template, :issue_branch_template=
+ delegate :show_default_award_emojis, :show_default_award_emojis=
+ delegate :enforce_auth_checks_on_uploads, :enforce_auth_checks_on_uploads=
+ delegate :warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=
+ end
+ end
# Validations
validates :creator, presence: true, on: :create
@@ -602,6 +597,42 @@ class Project < ApplicationRecord
.or(arel_table[:storage_version].eq(nil)))
end
+ scope :sorted_by_name_desc, -> {
+ keyset_order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: Project.arel_table[:name].desc,
+ distinct: false,
+ nullable: :nulls_last
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :id,
+ order_expression: Project.arel_table[:id].desc
+ )
+ ])
+
+ reorder(keyset_order)
+ }
+
+ scope :sorted_by_name_asc, -> {
+ keyset_order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: Project.arel_table[:name].asc,
+ distinct: false,
+ nullable: :nulls_last
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :id,
+ order_expression: Project.arel_table[:id].asc
+ )
+ ])
+
+ reorder(keyset_order)
+ }
+
scope :sorted_by_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) }
scope :sorted_by_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) }
scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
@@ -751,11 +782,12 @@ class Project < ApplicationRecord
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
default: 3600, error_message: N_('Maximum job timeout has a value which could not be accepted')
- validates :build_timeout, allow_nil: true,
- numericality: { greater_than_or_equal_to: 10.minutes,
- less_than: MAX_BUILD_TIMEOUT,
- only_integer: true,
- message: N_('needs to be between 10 minutes and 1 month') }
+ validates :build_timeout, allow_nil: true, numericality: {
+ greater_than_or_equal_to: 10.minutes,
+ less_than: MAX_BUILD_TIMEOUT,
+ only_integer: true,
+ message: N_('needs to be between 10 minutes and 1 month')
+ }
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
@@ -768,6 +800,20 @@ class Project < ApplicationRecord
preload(:project_feature, :route, :creator, group: :parent, namespace: [:route, :owner])
end
+ def self.with_slack_application_disabled
+ # Using Arel to avoid exposing what the column backing the type: attribute is
+ # rubocop: disable GitlabSecurity/PublicSend
+ with_active_slack = Integration.active.by_name(:gitlab_slack_application)
+ join_contraint = arel_table[:id].eq(Integration.arel_table[:project_id])
+ constraint = with_active_slack.where_clause.send(:predicates).reduce(join_contraint) { |a, b| a.and(b) }
+ join = arel_table.join(Integration.arel_table, Arel::Nodes::OuterJoin).on(constraint).join_sources
+ # rubocop: enable GitlabSecurity/PublicSend
+
+ joins(join).where(integrations: { id: nil })
+ rescue Integration::UnknownType
+ all
+ end
+
def self.eager_load_namespace_and_owner
includes(namespace: :owner)
end
@@ -782,9 +828,11 @@ class Project < ApplicationRecord
if user.is_a?(DeployToken)
where(id: user.accessible_projects)
else
- where('EXISTS (?) OR projects.visibility_level IN (?)',
- user.authorizations_for_projects(min_access_level: min_access_level),
- Gitlab::VisibilityLevel.levels_for_user(user))
+ where(
+ 'EXISTS (?) OR projects.visibility_level IN (?)',
+ user.authorizations_for_projects(min_access_level: min_access_level),
+ Gitlab::VisibilityLevel.levels_for_user(user)
+ )
end
end
@@ -863,11 +911,12 @@ class Project < ApplicationRecord
# search.
#
# query - The search query as a String.
- def search(query, include_namespace: false)
+ def search(query, include_namespace: false, use_minimum_char_limit: true)
if include_namespace
- joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description])
+ joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description],
+ use_minimum_char_limit: use_minimum_char_limit)
else
- fuzzy_search(query, [:path, :name, :description])
+ fuzzy_search(query, [:path, :name, :description], use_minimum_char_limit: use_minimum_char_limit)
end
end
@@ -1205,8 +1254,8 @@ class Project < ApplicationRecord
@repository ||= Gitlab::GlRepository::PROJECT.repository_for(self)
end
- def design_management_repository
- super || create_design_management_repository
+ def find_or_create_design_management_repository
+ design_management_repository || create_design_management_repository
end
def design_repository
@@ -1676,7 +1725,13 @@ class Project < ApplicationRecord
end
def disabled_integrations
- %w[shimo zentao]
+ return [] if Rails.env.development?
+
+ names = %w[shimo zentao]
+
+ # The Slack Slash Commands integration is only available for customers who cannot use the GitLab for Slack app.
+ # The GitLab for Slack app integration is only available when enabled through settings.
+ names << (Gitlab::CurrentSettings.slack_app_enabled ? 'slack_slash_commands' : 'gitlab_slack_application')
end
def find_or_initialize_integration(name)
@@ -2871,7 +2926,13 @@ class Project < ApplicationRecord
def default_branch_protected?
branch_protection = Gitlab::Access::BranchProtection.new(self.namespace.default_branch_protection)
- branch_protection.fully_protected? || branch_protection.developer_can_merge?
+ branch_protection.fully_protected? || branch_protection.developer_can_merge? || branch_protection.developer_can_initial_push?
+ end
+
+ def initial_push_to_default_branch_allowed_for_developer?
+ branch_protection = Gitlab::Access::BranchProtection.new(self.namespace.default_branch_protection)
+
+ !branch_protection.any? || branch_protection.developer_can_push? || branch_protection.developer_can_initial_push?
end
def environments_for_scope(scope)
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 772a82fa173..92ba02ec777 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -26,6 +26,7 @@ class ProjectFeature < ApplicationRecord
feature_flags
releases
infrastructure
+ model_experiments
].freeze
EXPORTABLE_FEATURES = (FEATURES - [:security_and_compliance, :pages]).freeze
@@ -79,6 +80,7 @@ class ProjectFeature < ApplicationRecord
attribute :infrastructure_access_level, default: ENABLED
attribute :feature_flags_access_level, default: ENABLED
attribute :environments_access_level, default: ENABLED
+ attribute :model_experiments_access_level, default: ENABLED
attribute :package_registry_access_level, default: -> do
if ::Gitlab.config.packages.enabled
@@ -132,12 +134,14 @@ class ProjectFeature < ApplicationRecord
min_access_level = required_minimum_access_level(feature)
column = quoted_access_level_column(feature)
- where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
- {
- public_visible: visible,
- private_visible: PRIVATE,
- authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id')
- })
+ where(
+ "#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
+ {
+ public_visible: visible,
+ private_visible: PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level, related_project_column: 'project_features.project_id')
+ }
+ )
else
# This has to be added to include features whose value is nil in the db
visible << nil
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index 3b514d5c5ff..7e0722ab68c 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -7,12 +7,12 @@ class ProjectImportData < ApplicationRecord
belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials,
- key: Settings.attr_encrypted_db_key_base,
- marshal: true,
- encode: true,
- mode: :per_attribute_iv_and_salt,
- insecure_mode: true,
- algorithm: 'aes-256-cbc'
+ key: Settings.attr_encrypted_db_key_base,
+ marshal: true,
+ encode: true,
+ mode: :per_attribute_iv_and_salt,
+ insecure_mode: true,
+ algorithm: 'aes-256-cbc'
# NOTE
# We are serializing a project as `data` in an "unsafe" way here
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
index 1256ef0f2fc..7ca74d4e970 100644
--- a/app/models/project_setting.rb
+++ b/app/models/project_setting.rb
@@ -31,6 +31,13 @@ class ProjectSetting < ApplicationRecord
encode: false,
encode_iv: false
+ attr_encrypted :product_analytics_configurator_connection_string,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ encode: false,
+ encode_iv: false
+
enum squash_option: {
never: 0,
always: 1,
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 732dadc03d9..14f6a90e5ed 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -132,11 +132,6 @@ class ProjectStatistics < ApplicationRecord
end
def self.bulk_increment_statistic(project, key, increments)
- unless Feature.enabled?(:project_statistics_bulk_increment, type: :development)
- total_amount = Gitlab::Counters::Increment.new(amount: increments.sum(&:amount))
- return increment_statistic(project, key, total_amount)
- end
-
return if project.pending_delete?
project.statistics.try do |project_statistics|
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index ca1064997af..fbdc88e7b76 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -183,9 +183,11 @@ class ProjectTeam
#
# Returns a Hash mapping user ID -> maximum access level.
def max_member_access_for_user_ids(user_ids)
- Gitlab::SafeRequestLoader.execute(resource_key: project.max_member_access_for_resource_key(User),
- resource_ids: user_ids,
- default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: project.max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS
+ ) do |user_ids|
project.project_authorizations
.where(user: user_ids)
.group(:user_id)
@@ -206,9 +208,11 @@ class ProjectTeam
end
def contribution_check_for_user_ids(user_ids)
- Gitlab::SafeRequestLoader.execute(resource_key: "contribution_check_for_users:#{project.id}",
- resource_ids: user_ids,
- default_value: false) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(
+ resource_key: "contribution_check_for_users:#{project.id}",
+ resource_ids: user_ids,
+ default_value: false
+ ) do |user_ids|
project.merge_requests
.merged
.where(author_id: user_ids, target_branch: project.default_branch.to_s)
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 3155eede2bd..ed1795b43e0 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -9,6 +9,7 @@ module Projects
validates :name, presence: true, length: { maximum: 255 }
validates :name, uniqueness: { case_sensitive: false }, if: :name_changed?
+ validate :validate_name_format, if: :name_changed?
validates :title, presence: true, length: { maximum: 255 }, on: :create
validates :description, length: { maximum: 1024 }
@@ -62,6 +63,18 @@ module Projects
where(id: topics_to_decrement).where('non_private_projects_count > 0').update_counters(non_private_projects_count: -1) unless topics_to_decrement.empty?
end
end
+
+ private
+
+ def validate_name_format
+ return if name.blank?
+
+ # /\R/ - A linebreak: \n, \v, \f, \r \u0085 (NEXT LINE),
+ # \u2028 (LINE SEPARATOR), \u2029 (PARAGRAPH SEPARATOR) or \r\n.
+ return unless name =~ /\R/
+
+ errors.add(:name, 'has characters that are not allowed')
+ end
end
end
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
index 59440947d71..52fc0a9d1bb 100644
--- a/app/models/prometheus_alert.rb
+++ b/app/models/prometheus_alert.rb
@@ -25,7 +25,7 @@ class PrometheusAlert < ApplicationRecord
validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true
validates :runbook_url, length: { maximum: 255 }, allow_blank: true,
- addressable_url: { enforce_sanitization: true, ascii_only: true }
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
validate :require_valid_environment_project!
validate :require_valid_metric_project!
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 09a0cfc91dc..aebce59a040 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -26,10 +26,16 @@ class ProtectedBranch < ApplicationRecord
end
def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil)
- # Maintainers, owners and admins are allowed to create the default branch
+ if project.empty_repo?
+ member_access = project.team.max_member_access(user.id)
- if project.empty_repo? && project.default_branch_protected?
+ # Admins are always allowed to create the default branch
return true if user.admin? || user.can?(:admin_project, project)
+
+ # Developers can push if it is allowed by default branch protection settings
+ if member_access == Gitlab::Access::DEVELOPER && project.initial_push_to_default_branch_allowed_for_developer?
+ return true
+ end
end
super
diff --git a/app/models/release.rb b/app/models/release.rb
index 0f00732b62e..7f74872cf67 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -35,8 +35,10 @@ class Release < ApplicationRecord
scope :sorted, -> { order(released_at: :desc) }
scope :preloaded, -> {
- includes(:author, :evidences, :milestones, :links, :sorted_links,
- project: [:project_feature, :route, { namespace: :route }])
+ includes(
+ :author, :evidences, :milestones, :links, :sorted_links,
+ project: [:project_feature, :route, { namespace: :route }]
+ )
}
scope :with_milestones, -> { joins(:milestone_releases) }
scope :with_group_milestones, -> { joins(:milestones).where.not(milestones: { group_id: nil }) }
@@ -54,6 +56,38 @@ class Release < ApplicationRecord
MAX_NUMBER_TO_DISPLAY = 3
+ class << self
+ # In the future, we should support `order_by=semver`;
+ # see https://gitlab.com/gitlab-org/gitlab/-/issues/352945
+ def latest(order_by: 'released_at')
+ sort_by_attribute("#{order_by}_desc").first
+ end
+
+ # This query uses LATERAL JOIN to find the latest release for each project. To avoid
+ # joining the `releases` table, we build an in-memory table using the project ids.
+ # Example:
+ # SELECT ...
+ # FROM (VALUES (PROJECT_ID_1),(PROJECT_ID_2)) project_ids (id)
+ # INNER JOIN LATERAL (...)
+ def latest_for_projects(projects, order_by: 'released_at')
+ return Release.none if projects.empty?
+
+ projects_table = Project.arel_table
+ releases_table = Release.arel_table
+
+ join_query = Release
+ .where(projects_table[:id].eq(releases_table[:project_id]))
+ .sort_by_attribute("#{order_by}_desc")
+ .limit(1)
+
+ project_ids_list = projects.map { |project| "(#{project.id})" }.join(',')
+
+ Release
+ .from("(VALUES #{project_ids_list}) projects (id)")
+ .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Release.table_name} ON TRUE")
+ end
+ end
+
def to_param
tag
end
diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb
index 7cead8a42cd..5a6f708f689 100644
--- a/app/models/release_highlight.rb
+++ b/app/models/release_highlight.rb
@@ -8,6 +8,12 @@ class ReleaseHighlight
ULTIMATE_PACKAGE = 'Ultimate'
def self.paginated(page: 1)
+ result = self.paginated_query(page: page)
+ result = self.paginated_query(page: result.next_page) while next_page?(result)
+ result
+ end
+
+ def self.paginated_query(page:)
key = self.cache_key("items:page-#{page}")
Rails.cache.fetch(key, expires_in: CACHE_DURATION) do
@@ -44,7 +50,7 @@ class ReleaseHighlight
rescue Psych::Exception => e
Gitlab::ErrorTracking.track_exception(e, file_path: file_path)
- nil
+ []
end
def self.whats_new_path
@@ -121,6 +127,14 @@ class ReleaseHighlight
item['available_in']&.include?(current_package)
end
+
+ def self.next_page?(result)
+ return false unless result
+
+ # if all items for the current page doesn't belong to the current tier
+ # or failed to parse current YAML, loading next page
+ result.items == [] && result.next_page.present?
+ end
end
ReleaseHighlight.prepend_mod
diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb
index 44760541290..3ad7efcfcec 100644
--- a/app/models/releases/source.rb
+++ b/app/models/releases/source.rb
@@ -9,9 +9,7 @@ module Releases
class << self
def all(project, tag_name)
Gitlab::Workhorse::ARCHIVE_FORMATS.map do |format|
- Releases::Source.new(project: project,
- tag_name: tag_name,
- format: format)
+ Releases::Source.new(project: project, tag_name: tag_name, format: format)
end
end
end
@@ -19,9 +17,7 @@ module Releases
def url
Gitlab::Routing
.url_helpers
- .project_archive_url(project,
- id: File.join(tag_name, archive_prefix),
- format: format)
+ .project_archive_url(project, id: File.join(tag_name, archive_prefix), format: format)
end
def hook_attrs
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index b830cf313af..8b2f3bdcedf 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -11,12 +11,12 @@ class RemoteMirror < ApplicationRecord
UNPROTECTED_BACKOFF_DELAY = 5.minutes
attr_encrypted :credentials,
- key: Settings.attr_encrypted_db_key_base,
- marshal: true,
- encode: true,
- mode: :per_attribute_iv_and_salt,
- insecure_mode: true,
- algorithm: 'aes-256-cbc'
+ key: Settings.attr_encrypted_db_key_base,
+ marshal: true,
+ encode: true,
+ mode: :per_attribute_iv_and_salt,
+ insecure_mode: true,
+ algorithm: 'aes-256-cbc'
belongs_to :project, inverse_of: :remote_mirrors
@@ -31,10 +31,8 @@ class RemoteMirror < ApplicationRecord
scope :stuck, -> do
started
- .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)',
- MAX_INCREMENTAL_RUNTIME.ago)
- .or(where('(last_update_started_at < ? AND last_update_at IS NULL)',
- MAX_FIRST_RUNTIME.ago))
+ .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)', MAX_INCREMENTAL_RUNTIME.ago)
+ .or(where('(last_update_started_at < ? AND last_update_at IS NULL)', MAX_FIRST_RUNTIME.ago))
end
state_machine :update_status, initial: :none do
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e942157993b..b21df6baf0e 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -691,7 +691,7 @@ class Repository
@head_tree ||= Tree.new(self, root_ref, nil, skip_flat_paths: skip_flat_paths)
end
- def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil)
+ def tree(sha = :head, path = nil, recursive: false, skip_flat_paths: true, pagination_params: nil, ref_type: nil)
if sha == :head
return if empty? || root_ref.nil?
@@ -699,10 +699,11 @@ class Repository
return head_tree(skip_flat_paths: skip_flat_paths)
else
sha = head_commit.sha
+ ref_type = nil
end
end
- Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params)
+ Tree.new(self, sha, path, recursive: recursive, skip_flat_paths: skip_flat_paths, pagination_params: pagination_params, ref_type: ref_type)
end
def blob_at_branch(branch_name, path)
@@ -880,10 +881,12 @@ class Repository
end
def merge(user, source_sha, merge_request, message)
- merge_to_branch(user,
- source_sha: source_sha,
- target_branch: merge_request.target_branch,
- message: message) do |commit_id|
+ merge_to_branch(
+ user,
+ source_sha: source_sha,
+ target_branch: merge_request.target_branch,
+ message: message
+ ) do |commit_id|
merge_request.update_and_mark_in_progress_merge_commit_sha(commit_id)
nil # Return value does not matter.
end
@@ -1136,10 +1139,13 @@ class Repository
end
def squash(user, merge_request, message)
- raw.squash(user, start_sha: merge_request.diff_start_sha,
- end_sha: merge_request.diff_head_sha,
- author: merge_request.author,
- message: message)
+ raw.squash(
+ user,
+ start_sha: merge_request.diff_start_sha,
+ end_sha: merge_request.diff_head_sha,
+ author: merge_request.author,
+ message: message
+ )
end
def submodule_links
@@ -1271,11 +1277,13 @@ class Repository
end
def initialize_raw_repository
- Gitlab::Git::Repository.new(shard,
- disk_path + '.git',
- repo_type.identifier_for_container(container),
- container.full_path,
- container: container)
+ Gitlab::Git::Repository.new(
+ shard,
+ disk_path + '.git',
+ repo_type.identifier_for_container(container),
+ container.full_path,
+ container: container
+ )
end
end
diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb
index 2cddfc393e3..59f88a63998 100644
--- a/app/models/resource_events/abuse_report_event.rb
+++ b/app/models/resource_events/abuse_report_event.rb
@@ -2,6 +2,8 @@
module ResourceEvents
class AbuseReportEvent < ApplicationRecord
+ include AbuseReportEventsHelper
+
belongs_to :abuse_report, optional: false
belongs_to :user
@@ -28,5 +30,9 @@ module ResourceEvents
other: 8,
unconfirmed: 9
}
+
+ def success_message
+ success_message_for_action(action)
+ end
end
end
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index dddd4d0fe84..1cc77501d8d 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -34,8 +34,9 @@ class ResourceTimeboxEvent < ResourceEvent
case self
when ResourceMilestoneEvent
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user,
- project: issue.project)
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(
+ author: user, project: issue.project
+ )
else
# no-op
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 580e4cd277c..c2fd8b20942 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -3,7 +3,7 @@
class SentNotification < ApplicationRecord
include IgnorableColumns
- serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
+ ignore_column %i[line_code note_type position], remove_with: '16.3', remove_after: '2023-07-22'
belongs_to :project
belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -46,7 +46,11 @@ class SentNotification < ApplicationRecord
commit_id: commit_id
)
- create(attrs)
+ # Non-sticky write is used as `.record` is only used in ActionMailer
+ # where there are no queries to SentNotification.
+ ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
+ create(attrs)
+ end
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
@@ -80,25 +84,6 @@ class SentNotification < ApplicationRecord
end
end
- def position=(new_position)
- if new_position.is_a?(String)
- new_position = begin
- Gitlab::Json.parse(new_position)
- rescue StandardError
- nil
- end
- end
-
- if new_position.is_a?(Hash)
- new_position = new_position.with_indifferent_access
- new_position = Gitlab::Diff::Position.new(new_position)
- else
- new_position = nil
- end
-
- super(new_position)
- end
-
def to_param
self.reply_key
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 3c40f4beedc..d4f8c1b3b0b 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -242,7 +242,7 @@ class Snippet < ApplicationRecord
end
def hook_attrs
- attributes
+ attributes.merge('url' => Gitlab::UrlBuilder.build(self))
end
def file_name
@@ -261,13 +261,12 @@ class Snippet < ApplicationRecord
notes.includes(:author)
end
- def check_for_spam?(user:)
- visibility_level_changed?(to: Snippet::PUBLIC) ||
- (public? && (title_changed? || description_changed?))
+ def check_for_spam?(*)
+ visibility_level_changed?(to: Snippet::PUBLIC) || (public? && spammable_attribute_changed?)
end
- def spammable_entity_type
- 'snippet'
+ def supports_recaptcha?
+ true
end
def to_ability_name
diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb
index 138feb6ab29..8ef2c579a5a 100644
--- a/app/models/snippet_user_mention.rb
+++ b/app/models/snippet_user_mention.rb
@@ -3,7 +3,7 @@
class SnippetUserMention < UserMention
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :snippet
belongs_to :note
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index 267be5fe5c2..58a154b8986 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -5,7 +5,7 @@ class Suggestion < ApplicationRecord
include Suggestible
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
belongs_to :note, inverse_of: :suggestions
validates :note, presence: true, unless: :importing?
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 0e0534d45ae..4e71a13a3a1 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -4,7 +4,7 @@ class SystemNoteMetadata < ApplicationRecord
include Importable
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
# These notes's action text might contain a reference that is external.
# We should always force a deep validation upon references that are found
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 93c128c989c..ecd3e27a9c4 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -28,7 +28,7 @@ module Terraform
validates :project_id, :name, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
- format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
attribute :uuid, default: -> { SecureRandom.hex(UUID_LENGTH / 2) }
diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb
index 246e78f31cb..67565039acd 100644
--- a/app/models/time_tracking/timelog_category.rb
+++ b/app/models/time_tracking/timelog_category.rb
@@ -18,9 +18,9 @@ module TimeTracking
validates :description, length: { maximum: 1024 }
validates :color, color: true, allow_blank: false, length: { maximum: 7 }
validates :billing_rate,
- if: :billable?,
- presence: true,
- numericality: { greater_than: 0 }
+ if: :billable?,
+ presence: true,
+ numericality: { greater_than: 0 }
DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc')
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index dc976816ad9..eb72456b435 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -3,8 +3,9 @@
class Timelog < ApplicationRecord
include Importable
include IgnorableColumns
+ include Sortable
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
before_save :set_project
@@ -45,11 +46,13 @@ class Timelog < ApplicationRecord
issue || merge_request
end
- def self.sort_by_field(field, direction)
- if direction == :asc
- order_scope_asc(field)
- else
- order_scope_desc(field)
+ def self.sort_by_field(field)
+ case field.to_s
+ when 'spent_at_asc' then order_scope_asc(:spent_at)
+ when 'spent_at_desc' then order_scope_desc(:spent_at)
+ when 'time_spent_asc' then order_scope_asc(:time_spent)
+ when 'time_spent_desc' then order_scope_desc(:time_spent)
+ else order_by(field)
end
end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index e1b5076e3d8..724f97c4812 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -6,7 +6,7 @@ class Todo < ApplicationRecord
include EachBatch
include IgnorableColumns
- ignore_column :note_id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
+ ignore_column :note_id_convert_to_bigint, remove_with: '16.2', remove_after: '2023-07-22'
# Time to wait for todos being removed when not visible for user anymore.
# Prevents TODOs being removed by mistake, for example, removing access from a user
diff --git a/app/models/tree.rb b/app/models/tree.rb
index c6adf5c263c..8622eb793c1 100644
--- a/app/models/tree.rb
+++ b/app/models/tree.rb
@@ -3,17 +3,25 @@
class Tree
include Gitlab::Utils::StrongMemoize
- attr_accessor :repository, :sha, :path, :entries, :cursor
+ attr_accessor :repository, :sha, :path, :entries, :cursor, :ref_type
- def initialize(repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil)
+ def initialize(
+ repository, sha, path = '/', recursive: false, skip_flat_paths: true, pagination_params: nil,
+ ref_type: nil)
path = '/' if path.blank?
@repository = repository
@sha = sha
@path = path
-
+ @ref_type = ExtractsRef.ref_type(ref_type)
git_repo = @repository.raw_repository
- @entries, @cursor = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive, skip_flat_paths, pagination_params)
+
+ ref = ExtractsRef.qualify_ref(@sha, ref_type)
+
+ @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, pagination_params)
+ @entries.each do |entry|
+ entry.ref_type = self.ref_type
+ end
end
def readme_path
diff --git a/app/models/uploads/fog.rb b/app/models/uploads/fog.rb
index d2b8eab9f0d..3c31909fb07 100644
--- a/app/models/uploads/fog.rb
+++ b/app/models/uploads/fog.rb
@@ -2,11 +2,8 @@
module Uploads
class Fog < Base
- include ::Gitlab::Utils::StrongMemoize
-
- def available?
- object_store.enabled
- end
+ include ::ObjectStorage::FogHelpers
+ extend ::Gitlab::Utils::Override
def keys(relation)
return [] unless available?
@@ -20,39 +17,9 @@ module Uploads
private
- def delete_object(key)
- return unless available?
-
- connection.delete_object(bucket_name, object_key(key))
-
- # So far, only GoogleCloudStorage raises an exception when the file is not found.
- # Other providers support idempotent requests and does not raise an error
- # when the file is missing.
- rescue ::Google::Apis::ClientError => e
- Gitlab::ErrorTracking.log_exception(e)
- end
-
- def object_store
- Gitlab.config.uploads.object_store
- end
-
- def bucket_name
- object_store.remote_directory
- end
-
- def object_key(key)
- # We allow administrators to create "sub buckets" by setting a prefix.
- # This makes it possible to deploy GitLab with only one object storage
- # bucket. This mirrors the implementation in app/uploaders/object_storage.rb.
- File.join([object_store.bucket_prefix, key].compact)
- end
-
- def connection
- return unless available?
-
- strong_memoize(:connection) do
- ::Fog::Storage.new(object_store.connection.to_hash.deep_symbolize_keys)
- end
+ override :storage_location_identifier
+ def storage_location_identifier
+ :uploads
end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 50da6f9e491..96cdbb192bc 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -31,6 +31,7 @@ class User < ApplicationRecord
include RestrictedSignup
include StripAttribute
include EachBatch
+ include SafelyChangeColumnDefault
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -56,8 +57,14 @@ class User < ApplicationRecord
FORBIDDEN_SEARCH_STATES = %w(blocked banned ldap_blocked).freeze
- add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
- add_authentication_token_field :feed_token
+ INCOMING_MAIL_TOKEN_PREFIX = 'glimt-'
+ FEED_TOKEN_PREFIX = 'glft-'
+
+ columns_changing_default :notified_of_own_activity
+
+ # lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens
+ add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token }
+ add_authentication_token_field :feed_token, format_with_prefix: :prefix_for_feed_token
add_authentication_token_field :static_object_token, encrypted: :optional
attribute :admin, default: false
@@ -91,6 +98,7 @@ class User < ApplicationRecord
# Must be included after `devise`
include EncryptedUserPassword
+ include RecoverableByAnyEmail
include AdminChangedPasswordNotifier
@@ -215,8 +223,11 @@ class User < ApplicationRecord
has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :abuse_reports, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent
- has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent
+ has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent
+ has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent
+ has_many :assigned_abuse_reports, foreign_key: :assignee_id, class_name: "AbuseReport", inverse_of: :assignee
+ has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by
+ has_many :abuse_events, foreign_key: :user_id, class_name: 'Abuse::Event', inverse_of: :user
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :abuse_trust_scores, class_name: 'Abuse::TrustScore', foreign_key: :user_id
has_many :builds, class_name: 'Ci::Build'
@@ -343,7 +354,7 @@ class User < ApplicationRecord
enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 }
# User's Project preference
- enum project_view: { readme: 0, activity: 1, files: 2 }
+ enum project_view: { readme: 0, activity: 1, files: 2, wiki: 3 }
# User's role
enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
@@ -360,6 +371,7 @@ class User < ApplicationRecord
:sourcegraph_enabled, :sourcegraph_enabled=,
:gitpod_enabled, :gitpod_enabled=,
:setup_for_company, :setup_for_company=,
+ :project_shortcut_buttons, :project_shortcut_buttons=,
:render_whitespace_in_code, :render_whitespace_in_code=,
:markdown_surround_selection, :markdown_surround_selection=,
:markdown_automatic_lists, :markdown_automatic_lists=,
@@ -960,6 +972,10 @@ class User < ApplicationRecord
def get_ids_by_ids_or_usernames(ids, usernames)
by_ids_or_usernames(ids, usernames).pluck(:id)
end
+
+ def generate_incoming_mail_token
+ "#{INCOMING_MAIL_TOKEN_PREFIX}#{SecureRandom.hex.to_i(16).to_s(36)}"
+ end
end
#
@@ -1664,16 +1680,19 @@ class User < ApplicationRecord
DELETION_DELAY_IN_DAYS = 7.days
def delete_async(deleted_by:, params: {})
- is_deleting_own_record = deleted_by.id == id
+ if should_delay_delete?(deleted_by)
+ new_note = format(_("User deleted own account on %{timestamp}"), timestamp: Time.zone.now)
+ self.note = "#{new_note}\n#{note}".strip
- if is_deleting_own_record && ::Feature.enabled?(:delay_delete_own_user)
- block
+ block_or_ban
DeleteUserWorker.perform_in(DELETION_DELAY_IN_DAYS, deleted_by.id, id, params.to_h)
- else
- block if params[:hard_delete]
- DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h)
+ return
end
+
+ block if params[:hard_delete]
+
+ DeleteUserWorker.perform_async(deleted_by.id, id, params.to_h)
end
# rubocop: disable CodeReuse/ServiceClass
@@ -2155,6 +2174,14 @@ class User < ApplicationRecord
callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
+ def dismissed_callout_before?(feature_name, dismissed_before)
+ callout = callouts_by_feature_name[feature_name]
+
+ return false unless callout
+
+ callout.dismissed_before?(dismissed_before)
+ end
+
def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil)
source_feature_name = "#{feature_name}_#{group.id}"
callout = group_callouts_by_feature_name[source_feature_name]
@@ -2252,10 +2279,26 @@ class User < ApplicationRecord
namespace_commit_emails.find_by(namespace: project.root_namespace)
end
+ def spammer?
+ spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
+ end
+
def spam_score
abuse_trust_scores.spamcheck.average(:score) || 0.0
end
+ def telesign_score
+ abuse_trust_scores.telesign.order(created_at: :desc).first&.score || 0.0
+ end
+
+ def arkose_global_score
+ abuse_trust_scores.arkose_global_score.order(created_at: :desc).first&.score || 0.0
+ end
+
+ def arkose_custom_score
+ abuse_trust_scores.arkose_custom_score.order(created_at: :desc).first&.score || 0.0
+ end
+
def trust_scores_for_source(source)
abuse_trust_scores.where(source: source)
end
@@ -2267,6 +2310,12 @@ class User < ApplicationRecord
}
end
+ def namespace_commit_email_for_namespace(namespace)
+ return if namespace.nil?
+
+ namespace_commit_emails.find_by(namespace: namespace)
+ end
+
protected
# override, from Devise::Validatable
@@ -2305,6 +2354,45 @@ class User < ApplicationRecord
private
+ def block_or_ban
+ if spammer? && account_age_in_days < 7
+ ban_and_report
+ else
+ block
+ end
+ end
+
+ def ban_and_report
+ msg = 'Potential spammer account deletion'
+ attrs = { user_id: id, reporter: User.security_bot, category: 'spam' }
+ abuse_report = AbuseReport.find_by(attrs)
+
+ if abuse_report.nil?
+ abuse_report = AbuseReport.create!(attrs.merge(message: msg))
+ else
+ abuse_report.update(message: "#{abuse_report.message}\n\n#{msg}")
+ end
+
+ UserCustomAttribute.set_banned_by_abuse_report(abuse_report)
+
+ ban
+ end
+
+ def has_possible_spam_contributions?
+ events
+ .for_action('commented')
+ .or(events.for_action('created').where(target_type: %w[Issue MergeRequest]))
+ .any?
+ end
+
+ def should_delay_delete?(deleted_by)
+ is_deleting_own_record = deleted_by.id == id
+
+ is_deleting_own_record &&
+ ::Feature.enabled?(:delay_delete_own_user) &&
+ has_possible_spam_contributions?
+ end
+
def pbkdf2?
return false unless otp_backup_codes&.any?
@@ -2357,9 +2445,10 @@ class User < ApplicationRecord
def authorized_groups_without_shared_membership
Group.from_union(
[
- groups.select(*Namespace.cached_column_list),
- authorized_projects.joins(:namespace).select(*Namespace.cached_column_list)
- ])
+ groups,
+ Group.id_in(authorized_projects.select(:namespace_id))
+ ]
+ )
end
def authorized_groups_with_shared_membership
@@ -2515,6 +2604,10 @@ class User < ApplicationRecord
Ci::NamespaceMirror.contains_traversal_ids(traversal_ids)
end
+
+ def prefix_for_feed_token
+ FEED_TOKEN_PREFIX
+ end
end
User.prepend_mod_with('User')
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index 9a186cb9038..63a5ee9770f 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -35,6 +35,14 @@ class UserCustomAttribute < ApplicationRecord
.select(:value)
end
+ def set_banned_by_abuse_report(abuse_report)
+ return unless abuse_report
+
+ custom_attribute = { user_id: abuse_report.user.id, key: AUTO_BANNED_BY_ABUSE_REPORT_ID, value: abuse_report.id }
+
+ upsert_custom_attributes([custom_attribute])
+ end
+
private
def blocked_users
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 293a20fcc5a..5c9a73571c0 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -5,6 +5,7 @@ class UserDetail < ApplicationRecord
extend ::Gitlab::Utils::Override
ignore_column :requires_credit_card_verification, remove_with: '16.1', remove_after: '2023-06-22'
+ ignore_column :provisioned_by_group_at, remove_with: '16.3', remove_after: '2023-07-22'
REGISTRATION_OBJECTIVE_PAIRS = { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5, joining_team: 6 }.freeze
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 90449411f8a..4d517408154 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -2,12 +2,15 @@
class UserPreference < ApplicationRecord
include IgnorableColumns
+ include SafelyChangeColumnDefault
# We could use enums, but Rails 4 doesn't support multiple
# enum options with same name for multiple fields, also it creates
# extra methods that aren't really needed here.
NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze
+ columns_changing_default :tab_width, :time_display_relative, :render_whitespace_in_code
+
belongs_to :user
scope :with_user, -> { joins(:user) }
@@ -35,6 +38,7 @@ class UserPreference < ApplicationRecord
attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT }
attribute :time_display_relative, default: true
attribute :render_whitespace_in_code, default: false
+ attribute :project_shortcut_buttons, default: true
enum visibility_pipeline_id_type: { id: 0, iid: 1 }
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 896cccfa0e5..38e518b6d3e 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -65,7 +65,14 @@ module Users
artifacts_management_page_feedback_banner: 62,
# 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233
branch_rules_info_callout: 65,
- create_runner_workflow_banner: 66
+ create_runner_workflow_banner: 66,
+ repository_storage_limit_banner_info_threshold: 67, # EE-only
+ repository_storage_limit_banner_warning_threshold: 68, # EE-only
+ repository_storage_limit_banner_alert_threshold: 69, # EE-only
+ repository_storage_limit_banner_error_threshold: 70, # EE-only
+ new_navigation_callout: 71,
+ code_suggestions_third_party_callout: 72, # EE-only
+ namespace_over_storage_users_combined_alert: 73 # EE-only
}
validates :feature_name,
diff --git a/app/models/users/calloutable.rb b/app/models/users/calloutable.rb
index 280a819e4d5..483d0d785a5 100644
--- a/app/models/users/calloutable.rb
+++ b/app/models/users/calloutable.rb
@@ -13,5 +13,9 @@ module Users
def dismissed_after?(dismissed_after)
dismissed_at > dismissed_after
end
+
+ def dismissed_before?(dismissed_before)
+ dismissed_at < dismissed_before
+ end
end
end
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 1cc9f1f50ad..c5946197b6f 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -25,7 +25,12 @@ module Users
preview_usage_quota_free_plan_alert: 15, # EE-only
enforcement_at_limit_alert: 16, # EE-only
web_hook_disabled: 17, # EE-only
- unlimited_members_during_trial_alert: 18 # EE-only
+ unlimited_members_during_trial_alert: 18, # EE-only
+ repository_storage_limit_banner_info_threshold: 19, # EE-only
+ repository_storage_limit_banner_warning_threshold: 20, # EE-only
+ repository_storage_limit_banner_alert_threshold: 21, # EE-only
+ repository_storage_limit_banner_error_threshold: 22, # EE-only
+ namespace_over_storage_users_combined_alert: 23 # EE-only
}
validates :group, presence: true
diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb
index 700e4e0e0ec..650e8942132 100644
--- a/app/models/vulnerability.rb
+++ b/app/models/vulnerability.rb
@@ -9,12 +9,6 @@ class Vulnerability < ApplicationRecord
scope :with_projects, -> { includes(:project) }
- # Policy class inferring logic is causing performance
- # issues therefore we need to explicitly set it.
- def self.declarative_policy_class
- :VulnerabilityPolicy
- end
-
def self.link_reference_pattern
nil
end
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 24d1078516e..9f28ffbf7b6 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -4,7 +4,7 @@ class WorkItem < Issue
include Gitlab::Utils::StrongMemoize
COMMON_QUICK_ACTIONS_COMMANDS = [
- :title, :reopen, :close, :cc, :tableflip, :shrug, :type
+ :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to
].freeze
self.table_name = 'issues'
@@ -168,8 +168,9 @@ class WorkItem < Issue
errors.add(
:work_item_type_id,
format(
- _('cannot be changed to %{new_type} with %{parent_type} as parent type.'),
- new_type: work_item_type.name, parent_type: parent_link.work_item_parent.work_item_type.name
+ _('cannot be changed to %{new_type} when linked to a parent %{parent_type}.'),
+ new_type: work_item_type.name.downcase,
+ parent_type: parent_link.work_item_parent.work_item_type.name.downcase
)
)
end
diff --git a/app/models/work_items/widgets/base.rb b/app/models/work_items/widgets/base.rb
index b54b84f1e1b..a8b1b3f9a59 100644
--- a/app/models/work_items/widgets/base.rb
+++ b/app/models/work_items/widgets/base.rb
@@ -16,9 +16,13 @@ module WorkItems
end
def self.callback_class
- Issuable::Callbacks.const_get(name.demodulize, false)
+ WorkItems::Callbacks.const_get(name.demodulize, false)
rescue NameError
- nil
+ begin
+ Issuable::Callbacks.const_get(name.demodulize, false)
+ rescue NameError
+ nil
+ end
end
def type
diff --git a/app/policies/audit_events/definition_policy.rb b/app/policies/audit_events/definition_policy.rb
new file mode 100644
index 00000000000..4109c59fb77
--- /dev/null
+++ b/app/policies/audit_events/definition_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module AuditEvents
+ class DefinitionPolicy < ::BasePolicy
+ condition(:read_audit_events_definitions_enabled) do
+ true
+ end
+
+ rule { read_audit_events_definitions_enabled }.enable :audit_event_definitions
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 285721de387..94a67f5b5c8 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -109,6 +109,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
@subject.runner_registration_enabled?
end
+ condition(:raise_admin_package_to_owner_enabled) do
+ Feature.enabled?(:raise_group_admin_package_permission_to_owner, @subject)
+ end
+
rule { can?(:read_group) & design_management_enabled }.policy do
enable :read_design_activity
end
@@ -159,6 +163,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :award_achievement
end
+ rule { can?(:owner_access) & achievements_enabled }.policy do
+ enable :destroy_user_achievement
+ end
+
rule { ~public_group & ~has_access }.prevent :read_counts
rule { ~can_read_group_member }.policy do
@@ -198,11 +206,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
enable :read_package
enable :read_crm_organization
enable :read_crm_contact
+ enable :read_confidential_issues
end
rule { maintainer }.policy do
enable :destroy_package
- enable :admin_package
enable :create_projects
enable :import_projects
enable :admin_pipeline
@@ -304,7 +312,11 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
rule { dependency_proxy_access_allowed & dependency_proxy_available }
.enable :read_dependency_proxy
- rule { maintainer & dependency_proxy_available }.policy do
+ rule { maintainer & dependency_proxy_available & ~raise_admin_package_to_owner_enabled }.policy do
+ enable :admin_dependency_proxy
+ end
+
+ rule { owner & dependency_proxy_available & raise_admin_package_to_owner_enabled }.policy do
enable :admin_dependency_proxy
end
@@ -370,6 +382,9 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy
# Should be matched with ProjectPolicy#read_internal_note
rule { admin | reporter }.enable :read_internal_note
+ rule { maintainer & ~raise_admin_package_to_owner_enabled }.enable :admin_package
+ rule { owner & raise_admin_package_to_owner_enabled }.enable :admin_package
+
def access_level(for_any_session: false)
return GroupMember::NO_ACCESS if @user.nil?
return GroupMember::NO_ACCESS unless user_is_user?
diff --git a/app/policies/organizations/organization_policy.rb b/app/policies/organizations/organization_policy.rb
new file mode 100644
index 00000000000..cac8d07811d
--- /dev/null
+++ b/app/policies/organizations/organization_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Organizations
+ class OrganizationPolicy < BasePolicy
+ rule { admin }.policy do
+ enable :admin_organization
+ end
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 47d8d0eef3e..c70dc288710 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -163,6 +163,14 @@ class ProjectPolicy < BasePolicy
condition(:service_desk_enabled) { @subject.service_desk_enabled? }
with_scope :subject
+ condition(:model_experiments_enabled) do
+ Feature.enabled?(:ml_experiment_tracking, @subject) && @subject.feature_available?(:model_experiments, @user)
+ end
+
+ with_scope :subject
+ condition(:model_registry_enabled) { Feature.enabled?(:model_registry, @subject) }
+
+ with_scope :subject
condition(:resource_access_token_feature_available) do
resource_access_token_feature_available?
end
@@ -220,6 +228,7 @@ class ProjectPolicy < BasePolicy
feature_flags
releases
infrastructure
+ model_experiments
]
features.each do |f|
@@ -892,6 +901,14 @@ class ProjectPolicy < BasePolicy
enable :add_catalog_resource
end
+ rule { model_registry_enabled }.policy do
+ enable :read_model_registry
+ end
+
+ rule { model_experiments_enabled }.policy do
+ enable :read_model_experiments
+ end
+
private
def user_is_user?
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 1078eda38e7..2fd198b8cf4 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -31,6 +31,7 @@ class UserPolicy < BasePolicy
enable :read_user_groups
enable :read_saved_replies
enable :read_user_email_address
+ enable :admin_user_email_address
end
rule { default }.enable :read_user_profile
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index f25436c54be..cd473152b41 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -56,23 +56,23 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def web_url
- url_helpers.project_blob_url(project, ref_qualified_path)
+ url_helpers.project_blob_url(*path_params)
end
def web_path
- url_helpers.project_blob_path(project, ref_qualified_path)
+ url_helpers.project_blob_path(*path_params)
end
def edit_blob_path
- url_helpers.project_edit_blob_path(project, ref_qualified_path)
+ url_helpers.project_edit_blob_path(*path_params)
end
def raw_path
- url_helpers.project_raw_path(project, ref_qualified_path)
+ url_helpers.project_raw_path(*path_params)
end
def replace_path
- url_helpers.project_update_blob_path(project, ref_qualified_path)
+ url_helpers.project_update_blob_path(*path_params)
end
def pipeline_editor_path
@@ -164,6 +164,18 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
private
+ def path_params
+ if ref_type.present?
+ [project, ref_qualified_path, { ref_type: ref_type }]
+ else
+ [project, ref_qualified_path]
+ end
+ end
+
+ def ref_type
+ blob.ref_type
+ end
+
def url_helpers
Gitlab::Routing.url_helpers
end
@@ -179,7 +191,12 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end
def ref_qualified_path
- File.join(blob.commit_id, blob.path)
+ # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`.
+ # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`.
+
+ commit_id = ExtractsRef.unqualify_ref(blob.commit_id, ref_type)
+
+ File.join(commit_id, blob.path)
end
def load_all_blob_data
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 8c9ff49b0e7..3aba5a2c7ed 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -65,7 +65,7 @@ module Ci
'%.2f' % pipeline.coverage
end
- def ref_text
+ def ref_text_legacy
if pipeline.detached_merge_request_pipeline?
_("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}")
.html_safe % {
@@ -87,6 +87,28 @@ module Ci
end
end
+ def ref_text
+ if pipeline.detached_merge_request_pipeline?
+ _("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}")
+ .html_safe % {
+ link_to_merge_request: link_to_merge_request,
+ link_to_merge_request_source_branch: link_to_merge_request_source_branch
+ }
+ elsif pipeline.merged_result_pipeline?
+ _("Related merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
+ .html_safe % {
+ link_to_merge_request: link_to_merge_request,
+ link_to_merge_request_source_branch: link_to_merge_request_source_branch,
+ link_to_merge_request_target_branch: link_to_merge_request_target_branch
+ }
+ elsif pipeline.ref && pipeline.ref_exists?
+ _("For %{link_to_pipeline_ref}")
+ .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
+ elsif pipeline.ref
+ _("For %{ref}").html_safe % { ref: plain_ref_name }
+ end
+ end
+
def all_related_merge_request_text(limit: nil)
if all_related_merge_requests.none?
_("No related merge requests found.")
@@ -106,7 +128,7 @@ module Ci
def link_to_pipeline_ref
ApplicationController.helpers.link_to(pipeline.ref,
project_commits_path(pipeline.project, pipeline.ref),
- class: "ref-name")
+ class: "ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2")
end
def link_to_merge_request
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 12f4b0496e4..8d2baa6ee99 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -197,7 +197,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def source_branch_link
if source_branch_exists?
- link_to(source_branch, source_branch_commits_path, class: 'ref-name')
+ link_to(source_branch, source_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2')
else
content_tag(:span, source_branch, class: 'ref-name')
end
@@ -205,7 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def target_branch_link
if target_branch_exists?
- link_to(target_branch, target_branch_commits_path, class: 'ref-name')
+ link_to(target_branch, target_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2')
else
content_tag(:span, target_branch, class: 'ref-name')
end
diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb
index 58ec2aee471..7f0bd9d6c11 100644
--- a/app/presenters/ml/candidate_details_presenter.rb
+++ b/app/presenters/ml/candidate_details_presenter.rb
@@ -53,7 +53,9 @@ module Ml
{
user: {
path: user_path(user),
- username: user.username
+ username: user.username,
+ name: user.name,
+ avatar: user.avatar_url
}
}
end
@@ -64,6 +66,7 @@ module Ml
{
merge_request: {
path: project_merge_request_path(mr.project, mr),
+ iid: mr.iid,
title: mr.title
}
}
diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb
index 0c7a81038dd..2fab074c69c 100644
--- a/app/presenters/packages/conan/package_presenter.rb
+++ b/app/presenters/packages/conan/package_presenter.rb
@@ -80,10 +80,9 @@ module Packages
def package_files
return unless @package
- strong_memoize(:package_files) do
- @package.installable_package_files.preload_conan_file_metadata
- end
+ @package.installable_package_files.preload_conan_file_metadata
end
+ strong_memoize_attr :package_files
def matching_reference?(package_file)
package_file.conan_file_metadatum.conan_package_reference == conan_package_reference
diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb
index 9f1dee17cea..f87f447fb23 100644
--- a/app/presenters/packages/nuget/packages_metadata_presenter.rb
+++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb
@@ -59,11 +59,10 @@ module Packages
end
def sorted_versions
- strong_memoize(:sorted_versions) do
- versions = @packages.map(&:version).compact
- VersionSorter.sort(versions)
- end
+ versions = @packages.filter_map(&:version)
+ sort_versions(versions)
end
+ strong_memoize_attr :sorted_versions
end
end
end
diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb
index 82ed80d8372..16c32a9d0d0 100644
--- a/app/presenters/packages/nuget/presenter_helpers.rb
+++ b/app/presenters/packages/nuget/presenter_helpers.rb
@@ -4,8 +4,8 @@ module Packages
module Nuget
module PresenterHelpers
include ::API::Helpers::RelatedResourcesHelpers
+ include Packages::Nuget::VersionHelpers
- BLANK_STRING = ''
PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup'
PACKAGE_DEPENDENCY = 'PackageDependency'
@@ -45,14 +45,13 @@ module Packages
def catalog_entry_for(package)
{
json_url: json_url_for(package),
- authors: BLANK_STRING,
dependency_groups: dependency_groups_for(package),
package_name: package.name,
package_version: package.version,
archive_url: archive_url_for(package),
- summary: BLANK_STRING,
tags: tags_for(package),
- metadatum: metadatum_for(package)
+ metadatum: metadatum_for(package),
+ published: package.created_at.iso8601
}
end
@@ -98,8 +97,8 @@ module Packages
metadatum = package.nuget_metadatum
return {} unless metadatum
- metadatum.slice(:project_url, :license_url, :icon_url)
- .compact
+ metadatum.slice(:authors, :description, :project_url, :license_url, :icon_url)
+ .compact
end
def base_path_for(package)
diff --git a/app/presenters/packages/nuget/search_results_presenter.rb b/app/presenters/packages/nuget/search_results_presenter.rb
index dc391c380f3..020e8656a36 100644
--- a/app/presenters/packages/nuget/search_results_presenter.rb
+++ b/app/presenters/packages/nuget/search_results_presenter.rb
@@ -14,26 +14,23 @@ module Packages
end
def data
- strong_memoize(:data) do
- @search.results.group_by(&:name).map do |package_name, packages|
- latest_version = latest_version(packages)
- latest_package = packages.find { |pkg| pkg.version == latest_version }
-
- {
- type: 'Package',
- authors: '',
- name: package_name,
- version: latest_version,
- versions: build_package_versions(packages),
- summary: '',
- total_downloads: 0,
- verified: true,
- tags: tags_for(latest_package),
- metadatum: metadatum_for(latest_package)
- }
- end
+ @search.results.group_by(&:name).map do |package_name, packages|
+ latest_version = latest_version(packages)
+ latest_package = packages.find { |pkg| pkg.version == latest_version }
+
+ {
+ type: 'Package',
+ name: package_name,
+ version: latest_version,
+ versions: build_package_versions(packages),
+ total_downloads: 0,
+ verified: true,
+ tags: tags_for(latest_package),
+ metadatum: metadatum_for(latest_package)
+ }
end
end
+ strong_memoize_attr :data
private
@@ -48,8 +45,8 @@ module Packages
end
def latest_version(packages)
- versions = packages.map(&:version).compact
- VersionSorter.sort(versions).last # rubocop: disable Style/RedundantSort
+ versions = packages.filter_map(&:version)
+ sort_versions(versions).last
end
end
end
diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb
index 033a1845c1c..b262735508c 100644
--- a/app/presenters/packages/nuget/service_index_presenter.rb
+++ b/app/presenters/packages/nuget/service_index_presenter.rb
@@ -35,12 +35,13 @@ module Packages
end
def resources
- available_services.map { |service| build_service(service) }
- .flatten
+ available_services.flat_map { |service| build_service(service) }
end
private
+ attr_reader :project_or_group
+
def available_services
case scope
when :group
@@ -77,13 +78,13 @@ module Packages
end
def scope
- return :project if @project_or_group.is_a?(::Project)
- return :group if @project_or_group.is_a?(::Group)
+ return :project if project_or_group.is_a?(::Project)
+ return :group if project_or_group.is_a?(::Group)
end
def download_service_url
params = {
- id: @project_or_group.id,
+ id: project_or_group.id,
package_name: nil,
package_version: nil,
package_filename: nil
@@ -97,7 +98,7 @@ module Packages
def metadata_service_url
params = {
- id: @project_or_group.id,
+ id: project_or_group.id,
package_name: nil,
package_version: nil
}
@@ -119,18 +120,18 @@ module Packages
def search_service_url
case scope
when :group
- api_v4_groups___packages_nuget_query_path(id: @project_or_group.id)
+ api_v4_groups___packages_nuget_query_path(id: project_or_group.id)
when :project
- api_v4_projects_packages_nuget_query_path(id: @project_or_group.id)
+ api_v4_projects_packages_nuget_query_path(id: project_or_group.id)
end
end
def publish_service_url
- api_v4_projects_packages_nuget_path(id: @project_or_group.id)
+ 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)
+ api_v4_projects_packages_nuget_symbolpackage_path(id: project_or_group.id)
end
end
end
diff --git a/app/presenters/packages/nuget/version_helpers.rb b/app/presenters/packages/nuget/version_helpers.rb
new file mode 100644
index 00000000000..8c9c82791b3
--- /dev/null
+++ b/app/presenters/packages/nuget/version_helpers.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Packages
+ module Nuget
+ module VersionHelpers
+ private
+
+ def sort_versions(versions)
+ versions.sort { |a, b| compare_versions(a, b) }
+ end
+
+ # NuGet version sorting algorithm as per https://semver.org/spec/v2.0.0.html#spec-item-11
+ def compare_versions(version_a, version_b)
+ return 0 if version_a == version_b
+ return 1 if version_b.nil?
+ return -1 if version_a.nil?
+
+ a_without_build_meta, a_build_meta = version_a.split('+', 2)
+ b_without_build_meta, b_build_meta = version_b.split('+', 2)
+
+ a_core, a_pre = a_without_build_meta.split(/-/, 2)
+ b_core, b_pre = b_without_build_meta.split(/-/, 2)
+
+ a_core_parts = a_core.split('.')
+ b_core_parts = b_core.split('.')
+
+ compare_core_parts(a_core_parts, b_core_parts) ||
+ compare_pre_release_parts(a_pre, b_pre) ||
+ pick_non_nil(a_pre, b_pre) ||
+ compare_build_meta_parts(a_build_meta, b_build_meta)
+ end
+
+ def compare_core_parts(a_core_parts, b_core_parts)
+ while a_core_parts.any? || b_core_parts.any?
+ a_part = a_core_parts.shift&.to_i || 0
+ b_part = b_core_parts.shift&.to_i || 0
+ return a_part <=> b_part if a_part != b_part
+ end
+ end
+
+ def compare_pre_release_parts(a_pre, b_pre)
+ return unless a_pre && b_pre
+
+ a_pre_parts = a_pre.split('.').map(&:downcase)
+ b_pre_parts = b_pre.split('.').map(&:downcase)
+
+ while a_pre_parts.any? || b_pre_parts.any?
+ a_pre_part = a_pre_parts.shift
+ b_pre_part = b_pre_parts.shift
+
+ # Empty parts are considered lower
+ return -1 if a_pre_part.nil?
+ return 1 if b_pre_part.nil?
+
+ a_num = a_pre_part.to_i
+ b_num = b_pre_part.to_i
+ next if a_num == b_num && a_pre_part.to_s == b_pre_part.to_s # Both are same numeric/alphanumeric parts
+
+ return select_numeric_before_alphanumeric(a_num, a_pre_part, b_num, b_pre_part) ||
+ compare_numeric_parts(a_pre_part, a_num, b_pre_part, b_num) ||
+ a_pre_part <=> b_pre_part
+ end
+ end
+
+ def compare_build_meta_parts(a_build_meta, b_build_meta)
+ (a_build_meta || '').casecmp(b_build_meta || '')
+ end
+
+ def select_numeric_before_alphanumeric(a_num, a_pre_part, b_num, b_pre_part)
+ return -1 if a_num != b_num && numeric?(a_pre_part) && !numeric?(b_pre_part)
+ return 1 if a_num != b_num && !numeric?(a_pre_part) && numeric?(b_pre_part)
+ end
+
+ def numeric?(pre_part)
+ !!Integer(pre_part, exception: false)
+ end
+
+ def compare_numeric_parts(a_pre_part, a_num, b_pre_part, b_num)
+ a_num <=> b_num if a_num != b_num && numeric?(a_pre_part) && numeric?(b_pre_part)
+ end
+
+ def pick_non_nil(var_a, var_b)
+ return -1 if var_a && !var_b
+ return 1 if !var_a && var_b
+ end
+ end
+ end
+end
diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb
index 0b313d81360..3f4a9f13c36 100644
--- a/app/presenters/tree_entry_presenter.rb
+++ b/app/presenters/tree_entry_presenter.rb
@@ -4,10 +4,23 @@ class TreeEntryPresenter < Gitlab::View::Presenter::Delegated
presents nil, as: :tree
def web_url
- Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path))
+ Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, ref_qualified_path,
+ ref_type: tree.ref_type)
end
def web_path
- Gitlab::Routing.url_helpers.project_tree_path(tree.repository.project, File.join(tree.commit_id, tree.path))
+ Gitlab::Routing.url_helpers.project_tree_path(tree.repository.project, ref_qualified_path,
+ ref_type: tree.ref_type)
+ end
+
+ private
+
+ def ref_qualified_path
+ # If `ref_type` is present the commit_id will include the ref qualifier e.g. `refs/heads/`.
+ # We only accept/return unqualified refs so we need to remove the qualifier from the `commit_id`.
+
+ commit_id = ExtractsRef.unqualify_ref(tree.commit_id, ref_type)
+
+ File.join(commit_id, tree.path)
end
end
diff --git a/app/presenters/work_item_presenter.rb b/app/presenters/work_item_presenter.rb
new file mode 100644
index 00000000000..995f2d02156
--- /dev/null
+++ b/app/presenters/work_item_presenter.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class WorkItemPresenter < IssuePresenter # rubocop:todo Gitlab/NamespacedClass
+end
diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb
index c0394eb38c5..f0e84fc44d2 100644
--- a/app/serializers/admin/abuse_report_details_entity.rb
+++ b/app/serializers/admin/abuse_report_details_entity.rb
@@ -71,6 +71,7 @@ module Admin
end
expose :report do
+ expose :status
expose :message
expose :created_at, as: :reported_at
expose :category
@@ -78,27 +79,9 @@ module Admin
expose :reported_content, as: :content
expose :reported_from_url, as: :url
expose :screenshot_path, as: :screenshot
- end
-
- expose :actions, if: ->(report) { report.user } do
- expose :user_blocked do |report|
- report.user.blocked?
- end
- expose :block_user_path do |report|
- block_admin_user_path(report.user)
- end
- expose :remove_report_path do |report|
+ expose :update_path do |report|
admin_abuse_report_path(report)
end
- expose :remove_user_and_report_path do |report|
- admin_abuse_report_path(report, remove_user: true)
- end
- expose :reported_user do |report|
- UserEntity.represent(report.user, only: [:name, :created_at])
- end
- expose :redirect_path do |_|
- admin_abuse_reports_path
- end
end
end
end
diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb
index 21ffdce155f..d7820dff6ef 100644
--- a/app/serializers/environment_serializer.rb
+++ b/app/serializers/environment_serializer.rb
@@ -83,7 +83,7 @@ class EnvironmentSerializer < BaseSerializer
def deployment_associations
{
user: [],
- cluster: [],
+ deployment_cluster: { cluster: [] },
project: {
route: [],
namespace: :route
diff --git a/app/serializers/integrations/harbor_serializers/repository_entity.rb b/app/serializers/integrations/harbor_serializers/repository_entity.rb
index f03465fe8e2..a6366ebfb36 100644
--- a/app/serializers/integrations/harbor_serializers/repository_entity.rb
+++ b/app/serializers/integrations/harbor_serializers/repository_entity.rb
@@ -47,8 +47,8 @@ module Integrations
private
def validate_path(path)
- Gitlab::Utils.check_path_traversal!(path)
- rescue ::Gitlab::Utils::PathTraversalAttackError
+ Gitlab::PathTraversal.check_path_traversal!(path)
+ rescue ::Gitlab::PathTraversal::PathTraversalAttackError
Gitlab::AppLogger.error("Path traversal attack detected #{path}")
''
end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index 6058c89d347..26dc748ad51 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -109,8 +109,6 @@ class NoteEntity < API::Entities::Note
end
def external_author
- return unless Feature.enabled?(:external_note_author_service_desk)
-
return unless object.note_metadata&.external_author
if can?(current_user, :read_external_emails, object.project)
diff --git a/app/serializers/profile/event_entity.rb b/app/serializers/profile/event_entity.rb
index fe90265c888..b769a80ef58 100644
--- a/app/serializers/profile/event_entity.rb
+++ b/app/serializers/profile/event_entity.rb
@@ -12,10 +12,12 @@ module Profile
expose(:action, if: ->(event) { include_private_event?(event) }) { |event| event_action(event) }
expose :ref, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
- expose(:type) { |event| event.ref_type } # rubocop:disable Style/SymbolProc
- expose(:count) { |event| event.ref_count } # rubocop:disable Style/SymbolProc
- expose(:name) { |event| event.ref_name } # rubocop:disable Style/SymbolProc
+ expose(:ref_type, as: :type)
+ expose(:ref_count, as: :count)
+ expose(:ref_name, as: :name)
expose(:path) { |event| ref_path(event) }
+ expose(:new_ref?, as: :is_new)
+ expose(:rm_ref?, as: :is_removed)
end
expose :commit, if: ->(event) { event.visible_to_user?(current_user) && event.push_action? } do
@@ -34,27 +36,35 @@ module Profile
end
end
- expose :author, if: ->(event) { include_private_event?(event) } do
- expose(:id) { |event| event.author.id }
- expose(:name) { |event| event.author.name }
- expose(:path) { |event| event.author.username }
- end
+ expose :author, if: ->(event) { include_private_event?(event) }, using: ::API::Entities::UserBasic
- expose :target, if: ->(event) { event.visible_to_user?(current_user) } do
- expose :target_type
+ expose :noteable, if: ->(event) { event.visible_to_user?(current_user) && event.note? } do
+ expose(:type) { |event| event.target.noteable_type }
+ expose(:reference_link_text) { |event| event.target.noteable.reference_link_text }
+ expose(:web_url) { |event| Gitlab::UrlBuilder.build(event.target.noteable) }
+ expose(:first_line_in_markdown) do |event|
+ first_line_in_markdown(event.target, :note, 150, project: event.project)
+ end
+ end
- expose(:title) { |event| event.target_title } # rubocop:disable Style/SymbolProc
- expose :target_url, if: ->(event) { event.target } do |event|
- Gitlab::UrlBuilder.build(event.target, only_path: true)
+ expose :target, if: ->(event) { event.target && event.visible_to_user?(current_user) } do
+ expose(:id) { |event| event.target.id }
+ expose(:target_type, as: :type)
+ expose(:target_title, as: :title)
+ expose(:issue_type, if: ->(event) { event.work_item? }) do |event|
+ event.target.issue_type
end
- expose :reference_link_text, if: ->(event) { event.target&.respond_to?(:reference_link_text) } do |event|
+
+ expose :reference_link_text, if: ->(event) { event.target.respond_to?(:reference_link_text) } do |event|
event.target.reference_link_text
end
- expose :first_line_in_markdown, if: ->(event) { event.note? && event.target && event.project } do |event|
- first_line_in_markdown(event.target, :note, 150, project: event.project)
- end
- expose :attachment, if: ->(event) { event.note? && event.target&.attachment } do
- expose(:url) { |event| event.target.attachment.url }
+
+ expose :web_url do |event|
+ if event.wiki_page?
+ event_wiki_page_target_url(event)
+ else
+ Gitlab::UrlBuilder.build(event.target)
+ end
end
end
@@ -62,6 +72,8 @@ module Profile
expose(:type) { |event| resource_parent_type(event) }
expose(:full_name) { |event| event.resource_parent&.full_name }
expose(:full_path) { |event| event.resource_parent&.full_path }
+ expose(:web_url) { |event| event.resource_parent&.web_url }
+ expose(:avatar_url) { |event| event.resource_parent&.avatar_url }
end
private
diff --git a/app/services/achievements/destroy_user_achievement_service.rb b/app/services/achievements/destroy_user_achievement_service.rb
new file mode 100644
index 00000000000..3beaed646e3
--- /dev/null
+++ b/app/services/achievements/destroy_user_achievement_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Achievements
+ class DestroyUserAchievementService
+ attr_reader :current_user, :user_achievement
+
+ def initialize(current_user, user_achievement)
+ @current_user = current_user
+ @user_achievement = user_achievement
+ end
+
+ def execute
+ return error_no_permissions unless allowed?
+
+ user_achievement.delete
+ ServiceResponse.success(payload: user_achievement)
+ end
+
+ private
+
+ def allowed?
+ current_user&.can?(:destroy_user_achievement, user_achievement)
+ end
+
+ def error_no_permissions
+ error('You have insufficient permissions to delete this user achievement')
+ end
+
+ def error(message)
+ ServiceResponse.error(message: Array(message))
+ end
+ end
+end
diff --git a/app/services/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb
index 5b2ad27ede4..12cf8bf14a8 100644
--- a/app/services/admin/abuse_report_update_service.rb
+++ b/app/services/admin/abuse_report_update_service.rb
@@ -17,8 +17,8 @@ module Admin
result = perform_action
if result[:status] == :success
- close_report_and_record_event
- ServiceResponse.success
+ event = close_report_and_record_event
+ ServiceResponse.success(message: event.success_message)
else
ServiceResponse.error(message: result[:message])
end
@@ -58,6 +58,8 @@ module Admin
end
def close_report
+ return error('Report already closed') if abuse_report.closed?
+
abuse_report.closed!
success
end
diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb
new file mode 100644
index 00000000000..a71d1f14112
--- /dev/null
+++ b/app/services/admin/plan_limits/update_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Admin
+ module PlanLimits
+ class UpdateService < ::BaseService
+ def initialize(params = {}, current_user:, plan:)
+ @current_user = current_user
+ @params = params
+ @plan = plan
+ end
+
+ def execute
+ return error(_('Access denied'), :forbidden) unless can_update?
+
+ if plan.actual_limits.update(parsed_params)
+ success
+ else
+ error(plan.actual_limits.errors.full_messages, :bad_request)
+ end
+ end
+
+ private
+
+ attr_accessor :current_user, :params, :plan
+
+ def can_update?
+ current_user.can_admin_all_resources?
+ end
+
+ # Overridden in EE
+ def parsed_params
+ params
+ end
+ end
+ end
+end
+
+Admin::PlanLimits::UpdateService.prepend_mod_with('Admin::PlanLimits::UpdateService')
diff --git a/app/services/alert_management/http_integrations/base_service.rb b/app/services/alert_management/http_integrations/base_service.rb
new file mode 100644
index 00000000000..980f18631c0
--- /dev/null
+++ b/app/services/alert_management/http_integrations/base_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ module HttpIntegrations
+ class BaseService < BaseProjectService
+ # @param project [Project]
+ # @param current_user [User]
+ # @param params [Hash]
+ def initialize(project, current_user, params)
+ @response = nil
+
+ super(project: project, current_user: current_user, params: params.with_indifferent_access)
+ end
+
+ private
+
+ def allowed?
+ current_user&.can?(:admin_operations, project)
+ end
+
+ def too_many_integrations?(integration)
+ AlertManagement::HttpIntegration
+ .for_project(integration.project_id)
+ .for_type(integration.type_identifier)
+ .id_not_in(integration.id)
+ .any?
+ end
+
+ def permitted_params
+ params.slice(*permitted_params_keys)
+ end
+
+ # overriden in EE
+ def permitted_params_keys
+ %i[name active type_identifier]
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(integration)
+ ServiceResponse.success(payload: { integration: integration.reset })
+ end
+
+ def error_multiple_integrations
+ error(_('Multiple integrations of a single type are not supported for this project'))
+ end
+
+ def error_on_save(integration)
+ error(integration.errors.full_messages.to_sentence)
+ end
+ end
+ end
+end
+
+::AlertManagement::HttpIntegrations::BaseService.prepend_mod
diff --git a/app/services/alert_management/http_integrations/create_service.rb b/app/services/alert_management/http_integrations/create_service.rb
index 1abe0548c45..17e39577c29 100644
--- a/app/services/alert_management/http_integrations/create_service.rb
+++ b/app/services/alert_management/http_integrations/create_service.rb
@@ -2,68 +2,34 @@
module AlertManagement
module HttpIntegrations
- class CreateService
- # @param project [Project]
- # @param current_user [User]
- # @param params [Hash]
- def initialize(project, current_user, params)
- @project = project
- @current_user = current_user
- @params = params.with_indifferent_access
- end
-
+ class CreateService < BaseService
def execute
return error_no_permissions unless allowed?
- return error_multiple_integrations unless creation_allowed?
-
- integration = project.alert_management_http_integrations.create(permitted_params)
- return error_in_create(integration) unless integration.valid?
-
- success(integration)
- end
- private
+ ::AlertManagement::HttpIntegration.transaction do
+ integration = project.alert_management_http_integrations.build(permitted_params)
- attr_reader :project, :current_user, :params
+ if integration.save
+ @response = success(integration)
- def allowed?
- current_user&.can?(:admin_operations, project)
- end
+ if too_many_integrations?(integration)
+ @response = error_multiple_integrations
- def creation_allowed?
- project.alert_management_http_integrations.empty?
- end
-
- def permitted_params
- params.slice(*permitted_params_keys)
- end
+ raise ActiveRecord::Rollback
+ end
+ else
+ @response = error_on_save(integration)
+ end
+ end
- # overriden in EE
- def permitted_params_keys
- %i[name active]
+ @response
end
- def error(message)
- ServiceResponse.error(message: message)
- end
-
- def success(integration)
- ServiceResponse.success(payload: { integration: integration })
- end
+ private
def error_no_permissions
error(_('You have insufficient permissions to create an HTTP integration for this project'))
end
-
- def error_multiple_integrations
- error(_('Multiple HTTP integrations are not supported for this project'))
- end
-
- def error_in_create(integration)
- error(integration.errors.full_messages.to_sentence)
- end
end
end
end
-
-::AlertManagement::HttpIntegrations::CreateService.prepend_mod_with('AlertManagement::HttpIntegrations::CreateService')
diff --git a/app/services/alert_management/http_integrations/destroy_service.rb b/app/services/alert_management/http_integrations/destroy_service.rb
index aeb3f6cb807..1bd73ca46e4 100644
--- a/app/services/alert_management/http_integrations/destroy_service.rb
+++ b/app/services/alert_management/http_integrations/destroy_service.rb
@@ -12,6 +12,7 @@ module AlertManagement
def execute
return error_no_permissions unless allowed?
+ return error_legacy_prometheus unless destroy_allowed?
if integration.destroy
success
@@ -28,6 +29,12 @@ module AlertManagement
current_user&.can?(:admin_operations, integration)
end
+ # Prevents downtime while migrating from Integrations::Prometheus.
+ # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734
+ def destroy_allowed?
+ !(integration.legacy? && integration.prometheus?)
+ end
+
def error(message)
ServiceResponse.error(message: message)
end
@@ -39,6 +46,10 @@ module AlertManagement
def error_no_permissions
error(_('You have insufficient permissions to remove this HTTP integration'))
end
+
+ def error_legacy_prometheus
+ error(_('Legacy Prometheus integrations cannot currently be removed'))
+ end
end
end
end
diff --git a/app/services/alert_management/http_integrations/update_service.rb b/app/services/alert_management/http_integrations/update_service.rb
index 8662f966a2e..f7a079576e4 100644
--- a/app/services/alert_management/http_integrations/update_service.rb
+++ b/app/services/alert_management/http_integrations/update_service.rb
@@ -2,51 +2,48 @@
module AlertManagement
module HttpIntegrations
- class UpdateService
+ class UpdateService < BaseService
# @param integration [AlertManagement::HttpIntegration]
# @param current_user [User]
# @param params [Hash]
def initialize(integration, current_user, params)
@integration = integration
- @current_user = current_user
- @params = params.with_indifferent_access
+
+ super(integration.project, current_user, params)
end
def execute
return error_no_permissions unless allowed?
- params[:token] = nil if params.delete(:regenerate_token)
+ integration.transaction do
+ if integration.update(permitted_params.merge(token_params))
+ @response = success(integration)
+
+ if type_update? && too_many_integrations?(integration)
+ @response = error_multiple_integrations
- if integration.update(permitted_params)
- success
- else
- error(integration.errors.full_messages.to_sentence)
+ raise ActiveRecord::Rollback
+ end
+ else
+ @response = error_on_save(integration)
+ end
end
+
+ @response
end
private
- attr_reader :integration, :current_user, :params
+ attr_reader :integration
- def allowed?
- current_user&.can?(:admin_operations, integration)
- end
+ def token_params
+ return {} unless params[:regenerate_token]
- def permitted_params
- params.slice(*permitted_params_keys)
+ { token: nil }
end
- # overriden in EE
- def permitted_params_keys
- %i[name active token]
- end
-
- def error(message)
- ServiceResponse.error(message: message)
- end
-
- def success
- ServiceResponse.success(payload: { integration: integration.reset })
+ def type_update?
+ params[:type_identifier].present?
end
def error_no_permissions
@@ -55,5 +52,3 @@ module AlertManagement
end
end
end
-
-::AlertManagement::HttpIntegrations::UpdateService.prepend_mod_with('AlertManagement::HttpIntegrations::UpdateService')
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index e0594247975..556f04e8786 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -6,9 +6,10 @@ module AlertManagement
include ::AlertManagement::AlertProcessing
include ::AlertManagement::Responses
- def initialize(project, payload)
+ def initialize(project, payload, integration: nil)
@project = project
@payload = payload
+ @integration = integration
end
def execute
@@ -24,7 +25,7 @@ module AlertManagement
private
- attr_reader :project, :payload
+ attr_reader :project, :payload, :integration
override :incoming_payload
def incoming_payload
@@ -32,6 +33,7 @@ module AlertManagement
Gitlab::AlertManagement::Payload.parse(
project,
payload,
+ integration: integration,
monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
)
end
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index d18f2935d92..2bbb8f925a4 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -11,9 +11,14 @@ module AutoMerge
end
def process(merge_request)
+ logger.info("Processing Automerge")
return unless merge_request.actual_head_pipeline_success?
+
+ logger.info("Pipeline Success")
return unless merge_request.mergeable?
+ logger.info("Merge request mergeable")
+
merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params)
end
@@ -40,5 +45,9 @@ module AutoMerge
def notify(merge_request)
notification_service.async.merge_when_pipeline_succeeds(merge_request, current_user) if merge_request.saved_change_to_auto_merge_enabled?
end
+
+ def logger
+ @logger ||= Gitlab::AppLogger
+ end
end
end
diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb
index 77e297b6b11..04b5d826416 100644
--- a/app/services/boards/issues/create_service.rb
+++ b/app/services/boards/issues/create_service.rb
@@ -32,7 +32,7 @@ module Boards
def create_issue(params)
# 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(container: project, current_user: current_user, params: params, spam_params: nil).execute
+ ::Issues::CreateService.new(container: project, current_user: current_user, params: params, perform_spam_check: false).execute
end
end
end
diff --git a/app/services/bulk_imports/archive_extraction_service.rb b/app/services/bulk_imports/archive_extraction_service.rb
index fec8fd0e1f5..4485b19035b 100644
--- a/app/services/bulk_imports/archive_extraction_service.rb
+++ b/app/services/bulk_imports/archive_extraction_service.rb
@@ -41,11 +41,11 @@ module BulkImports
attr_reader :tmpdir, :filename, :filepath
def validate_filepath
- Gitlab::Utils.check_path_traversal!(filepath)
+ Gitlab::PathTraversal.check_path_traversal!(filepath)
end
def validate_tmpdir
- Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
+ Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
end
def validate_symlink
diff --git a/app/services/bulk_imports/create_service.rb b/app/services/bulk_imports/create_service.rb
index 4c9c59ac504..636c636255f 100644
--- a/app/services/bulk_imports/create_service.rb
+++ b/app/services/bulk_imports/create_service.rb
@@ -118,9 +118,10 @@ module BulkImports
end
client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status")
+ rescue BulkImports::NetworkError => e
# the source instance will return a 404 if the feature is disabled as the endpoint won't be available
- rescue Gitlab::HTTP::BlockedUrlError
- rescue BulkImports::NetworkError
+ return if e.cause.is_a?(Gitlab::HTTP::BlockedUrlError)
+
raise ::BulkImports::Error.setting_not_enabled
end
diff --git a/app/services/bulk_imports/file_decompression_service.rb b/app/services/bulk_imports/file_decompression_service.rb
index 41616fc1c75..94573f6bb13 100644
--- a/app/services/bulk_imports/file_decompression_service.rb
+++ b/app/services/bulk_imports/file_decompression_service.rb
@@ -41,11 +41,11 @@ module BulkImports
attr_reader :tmpdir, :filename, :filepath, :decompressed_filename, :decompressed_filepath
def validate_filepath
- Gitlab::Utils.check_path_traversal!(filepath)
+ Gitlab::PathTraversal.check_path_traversal!(filepath)
end
def validate_tmpdir
- Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
+ Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
end
def validate_decompressed_file_size
diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb
index ee499c782b4..ef7e0ae8258 100644
--- a/app/services/bulk_imports/file_download_service.rb
+++ b/app/services/bulk_imports/file_download_service.rb
@@ -99,7 +99,7 @@ module BulkImports
end
def validate_tmpdir
- Gitlab::Utils.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
+ Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
end
def filepath
diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb
new file mode 100644
index 00000000000..b5c8c00273e
--- /dev/null
+++ b/app/services/ci/cancel_pipeline_service.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+module Ci
+ # Cancel a pipelines cancelable jobs and optionally it's child pipelines cancelable jobs
+ class CancelPipelineService
+ include Gitlab::OptimisticLocking
+ include Gitlab::Allowable
+
+ ##
+ # @cascade_to_children - if true cancels all related child pipelines for parent child pipelines
+ # @auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation
+ # @execute_async - if true cancel the children asyncronously
+ def initialize(
+ pipeline:,
+ current_user:,
+ cascade_to_children: true,
+ auto_canceled_by_pipeline_id: nil,
+ execute_async: true)
+ @pipeline = pipeline
+ @current_user = current_user
+ @cascade_to_children = cascade_to_children
+ @auto_canceled_by_pipeline_id = auto_canceled_by_pipeline_id
+ @execute_async = execute_async
+ end
+
+ def execute
+ unless can?(current_user, :update_pipeline, pipeline)
+ return ServiceResponse.error(
+ message: 'Insufficient permissions to cancel the pipeline',
+ reason: :insufficient_permissions)
+ end
+
+ force_execute
+ end
+
+ # This method should be used only when we want to always cancel the pipeline without
+ # checking whether the current_user has permissions to do so, or when we don't have
+ # a current_user available in the context.
+ def force_execute
+ return ServiceResponse.error(message: 'No pipeline provided', reason: :no_pipeline) unless pipeline
+
+ unless pipeline.cancelable?
+ return ServiceResponse.error(message: 'Pipeline is not cancelable', reason: :pipeline_not_cancelable)
+ end
+
+ log_pipeline_being_canceled
+
+ pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline_id) if @auto_canceled_by_pipeline_id
+ cancel_jobs(pipeline.cancelable_statuses)
+
+ return ServiceResponse.success unless cascade_to_children?
+
+ # cancel any bridges that could spin up new child pipelines
+ cancel_jobs(pipeline.bridges_in_self_and_project_descendants.cancelable)
+ cancel_children
+
+ ServiceResponse.success
+ end
+
+ private
+
+ attr_reader :pipeline, :current_user
+
+ def log_pipeline_being_canceled
+ Gitlab::AppJsonLogger.info(
+ event: 'pipeline_cancel_running',
+ pipeline_id: pipeline.id,
+ auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id,
+ cascade_to_children: cascade_to_children?,
+ execute_async: execute_async?,
+ **Gitlab::ApplicationContext.current
+ )
+ end
+
+ def cascade_to_children?
+ @cascade_to_children
+ end
+
+ def execute_async?
+ @execute_async
+ end
+
+ def cancel_jobs(jobs)
+ retries = 3
+ retry_lock(jobs, retries, name: 'ci_pipeline_cancel_running') do |jobs_to_cancel|
+ preloaded_relations = [:project, :pipeline, :deployment, :taggings]
+
+ jobs_to_cancel.find_in_batches do |batch|
+ relation = CommitStatus.id_in(batch)
+ Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations)
+
+ relation.each do |job|
+ job.auto_canceled_by_id = @auto_canceled_by_pipeline_id if @auto_canceled_by_pipeline_id
+ job.cancel
+ end
+ end
+ end
+ end
+
+ # For parent child-pipelines only (not multi-project)
+ def cancel_children
+ pipeline.all_child_pipelines.each do |child_pipeline|
+ if execute_async?
+ ::Ci::CancelPipelineWorker.perform_async(
+ child_pipeline.id,
+ @auto_canceled_by_pipeline_id
+ )
+ else
+ # cascade_to_children is false because we iterate through children
+ # we also cancel bridges prior to prevent more children
+ self.class.new(
+ pipeline: child_pipeline.reset,
+ current_user: nil,
+ cascade_to_children: false,
+ execute_async: execute_async?,
+ auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id
+ ).force_execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ci/delete_unit_tests_service.rb b/app/services/ci/delete_unit_tests_service.rb
index 230661a107d..a2fb44ff3fc 100644
--- a/app/services/ci/delete_unit_tests_service.rb
+++ b/app/services/ci/delete_unit_tests_service.rb
@@ -25,9 +25,7 @@ module Ci
klass.transaction do
ids = klass.deletable.lock('FOR UPDATE SKIP LOCKED').limit(BATCH_SIZE).pluck(:id)
- break if ids.empty?
-
- deleted = klass.where(id: ids).delete_all
+ deleted = klass.where(id: ids).delete_all if ids.any?
end
deleted > 0
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index 1c563396162..bdec13f98a7 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -7,7 +7,13 @@ module Ci
Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
- pipeline.cancel_running(cascade_to_children: true, execute_async: false) if pipeline.cancelable?
+ # ensure cancellation happens sync so we accumulate compute credits successfully
+ # before deleting the pipeline.
+ ::Ci::CancelPipelineService.new(
+ pipeline: pipeline,
+ current_user: current_user,
+ cascade_to_children: true,
+ execute_async: false).force_execute
# The pipeline, the builds, job and pipeline artifacts all get destroyed here.
# Ci::Pipeline#destroy triggers fast destroy on job_artifacts and
diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb
index f7e04c59463..3ac0e83232f 100644
--- a/app/services/ci/job_artifacts/create_service.rb
+++ b/app/services/ci/job_artifacts/create_service.rb
@@ -26,7 +26,8 @@ module Ci
headers = JobArtifactUploader.workhorse_authorize(
has_length: false,
maximum_size: max_size(artifact_type),
- use_final_store_path: Feature.enabled?(:ci_artifacts_upload_to_final_location, project)
+ use_final_store_path: true,
+ final_store_path_root_id: project.id
)
if lsif?(artifact_type)
diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb
index d6a2defd5b9..eddd4d79484 100644
--- a/app/services/ci/job_token_scope/remove_project_service.rb
+++ b/app/services/ci/job_token_scope/remove_project_service.rb
@@ -26,7 +26,7 @@ module Ci
ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { project_link: link })
end
rescue EditScopeValidations::ValidationError => e
- ServiceResponse.error(message: e.message)
+ ServiceResponse.error(message: e.message, reason: :insufficient_permissions)
end
end
end
diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
index 48c3e6490ae..e197821a0c0 100644
--- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
+++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb
@@ -6,6 +6,7 @@ module Ci
include Gitlab::Utils::StrongMemoize
BATCH_SIZE = 25
+ PAGE_SIZE = 500
def initialize(pipeline)
@pipeline = pipeline
@@ -14,13 +15,24 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def execute
+ return if service_disabled?
return if pipeline.parent_pipeline? # skip if child pipeline
return unless project.auto_cancel_pending_pipelines?
- Gitlab::OptimisticLocking
- .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables|
- cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch|
- auto_cancel_interruptible_pipelines(cancelables_batch.ids)
+ if Feature.enabled?(:use_offset_pagination_for_canceling_redundant_pipelines, project)
+ paginator.each do |ids|
+ pipelines = parent_and_child_pipelines(ids)
+
+ Gitlab::OptimisticLocking.retry_lock(pipelines, name: 'cancel_pending_pipelines') do |cancelables|
+ auto_cancel_interruptible_pipelines(cancelables.ids)
+ end
+ end
+ else
+ Gitlab::OptimisticLocking
+ .retry_lock(parent_and_child_pipelines, name: 'cancel_pending_pipelines') do |cancelables|
+ cancelables.select(:id).each_batch(of: BATCH_SIZE) do |cancelables_batch|
+ auto_cancel_interruptible_pipelines(cancelables_batch.ids)
+ end
end
end
end
@@ -29,17 +41,40 @@ module Ci
attr_reader :pipeline, :project
- def parent_auto_cancelable_pipelines
- project.all_pipelines
+ def paginator
+ page = 1
+ Enumerator.new do |yielder|
+ loop do
+ # leverage the index_ci_pipelines_on_project_id_and_status_and_created_at index
+ records = project.all_pipelines
+ .created_after(1.week.ago)
+ .order(:status, :created_at)
+ .page(page) # use offset pagination because there is no other way to loop over the data
+ .per(PAGE_SIZE)
+ .pluck(:id)
+
+ raise StopIteration if records.empty?
+
+ yielder << records
+ page += 1
+ end
+ end
+ end
+
+ def parent_auto_cancelable_pipelines(ids = nil)
+ scope = project.all_pipelines
.created_after(1.week.ago)
.for_ref(pipeline.ref)
.where_not_sha(project.commit(pipeline.ref).try(:id))
.where("created_at < ?", pipeline.created_at)
.ci_sources
+
+ scope = scope.id_in(ids) if ids.present?
+ scope
end
- def parent_and_child_pipelines
- Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines, project_condition: :same)
+ def parent_and_child_pipelines(ids = nil)
+ Ci::Pipeline.object_hierarchy(parent_auto_cancelable_pipelines(ids), project_condition: :same)
.base_and_descendants
.alive_or_scheduled
end
@@ -59,12 +94,23 @@ module Ci
)
# cascade_to_children not needed because we iterate through descendants here
- cancelable_pipeline.cancel_running(
+ ::Ci::CancelPipelineService.new(
+ pipeline: cancelable_pipeline,
+ current_user: nil,
auto_canceled_by_pipeline_id: pipeline.id,
cascade_to_children: false
- )
+ ).force_execute
end
end
+
+ # Finding the pipelines to cancel is an expensive task that is not well
+ # covered by indexes for all project use-cases and sometimes it might
+ # harm other services. See https://gitlab.com/gitlab-com/gl-infra/production/-/issues/14758
+ # This feature flag is in place to disable this feature for rogue projects.
+ #
+ def service_disabled?
+ Feature.enabled?(:disable_cancel_redundant_pipelines_service, project, type: :ops)
+ end
end
end
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 1094a131e68..c0ffbb401f6 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -22,9 +22,19 @@ module Ci
# Run the process only if we can obtain an exclusive lease; returns nil if lease is unavailable
success = try_obtain_lease { process! }
- # Re-schedule if we need further processing
- if success && pipeline.needs_processing?
- PipelineProcessWorker.perform_async(pipeline.id)
+ if success
+ if ::Feature.enabled?(:ci_reset_skipped_jobs_in_atomic_processing, project)
+ # If any jobs changed from stopped to alive status during pipeline processing, we must
+ # re-reset their dependent jobs; see https://gitlab.com/gitlab-org/gitlab/-/issues/388539.
+ new_alive_jobs.group_by(&:user).each do |user, jobs|
+ log_running_reset_skipped_jobs_service(jobs)
+
+ ResetSkippedJobsService.new(project, user).execute(jobs)
+ end
+ end
+
+ # Re-schedule if we need further processing
+ PipelineProcessWorker.perform_async(pipeline.id) if pipeline.needs_processing?
end
success
@@ -105,6 +115,25 @@ module Ci
end
end
+ # Gets the jobs that changed from stopped to alive status since the initial status collection
+ # was evaluated. We determine this by checking if their current status is no longer stopped.
+ def new_alive_jobs
+ initial_stopped_job_names = @collection.stopped_job_names
+
+ return [] if initial_stopped_job_names.empty?
+
+ new_collection = AtomicProcessingService::StatusCollection.new(pipeline)
+ new_alive_job_names = initial_stopped_job_names - new_collection.stopped_job_names
+
+ return [] if new_alive_job_names.empty?
+
+ pipeline
+ .current_jobs
+ .by_name(new_alive_job_names)
+ .preload(:user) # rubocop: disable CodeReuse/ActiveRecord
+ .to_a
+ end
+
def project
pipeline.project
end
@@ -116,6 +145,17 @@ module Ci
def lease_timeout
DEFAULT_LEASE_TIMEOUT
end
+
+ def log_running_reset_skipped_jobs_service(jobs)
+ Gitlab::AppJsonLogger.info(
+ class: self.class.name.to_s,
+ message: 'Running ResetSkippedJobsService on new alive jobs',
+ project_id: project.id,
+ pipeline_id: pipeline.id,
+ user_id: jobs.first.user.id,
+ jobs_count: jobs.count
+ )
+ end
end
end
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
index 85646b79254..9a53c6d8fc1 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -67,6 +67,11 @@ module Ci
all_jobs.lazy.reject { |job| job[:processed] }
end
+ # This method returns the names of jobs that have a stopped status
+ def stopped_job_names
+ all_jobs.select { |job| job[:status].in?(Ci::HasStatus::STOPPED_STATUSES) }.pluck(:name) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
private
# We use these columns to perform an efficient calculation of a status
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
index 1a5c8d0dccf..dfbb37cf0dc 100644
--- a/app/services/ci/pipelines/add_job_service.rb
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -18,12 +18,6 @@ module Ci
in_lock("ci:pipelines:#{pipeline.id}:add-job", ttl: LOCK_TIMEOUT, sleep_sec: LOCK_SLEEP, retries: LOCK_RETRIES) do
Ci::Pipeline.transaction do
- # This is used to reduce the deadlocks when partitioning `ci_builds`
- # since inserting into this table requires locks on all foreign keys
- # and we need to lock all the tables in a specific order for the
- # migration to succeed.
- Ci::Pipeline.connection.execute('LOCK "ci_pipelines", "ci_stages" IN ROW SHARE MODE;')
-
yield(job)
job.update_older_statuses_retried!
diff --git a/app/services/ci/reset_skipped_jobs_service.rb b/app/services/ci/reset_skipped_jobs_service.rb
index cb793eb3e06..9e5c887b31b 100644
--- a/app/services/ci/reset_skipped_jobs_service.rb
+++ b/app/services/ci/reset_skipped_jobs_service.rb
@@ -7,7 +7,6 @@ module Ci
def execute(processables)
@processables = Array.wrap(processables)
@pipeline = @processables.first.pipeline
- @processable = @processables.first # Remove with FF `ci_support_reset_skipped_jobs_for_multiple_jobs`
process_subsequent_jobs
reset_source_bridge
@@ -43,27 +42,17 @@ module Ci
end
def stage_dependent_jobs
- if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project)
- # Get all jobs after the earliest stage of the inputted jobs
- min_stage_idx = @processables.map(&:stage_idx).min
- @pipeline.processables.after_stage(min_stage_idx)
- else
- @pipeline.processables.after_stage(@processable.stage_idx)
- end
+ # Get all jobs after the earliest stage of the inputted jobs
+ min_stage_idx = @processables.map(&:stage_idx).min
+ @pipeline.processables.after_stage(min_stage_idx)
end
def needs_dependent_jobs
- if ::Feature.enabled?(:ci_support_reset_skipped_jobs_for_multiple_jobs, project)
- # We must include the hierarchy base here because @processables may include both a parent job
- # and its dependents, and we do not want to exclude those dependents from being processed.
- ::Gitlab::Ci::ProcessableObjectHierarchy.new(
- ::Ci::Processable.where(id: @processables.map(&:id))
- ).base_and_descendants
- else
- ::Gitlab::Ci::ProcessableObjectHierarchy.new(
- ::Ci::Processable.where(id: @processable.id)
- ).descendants
- end
+ # We must include the hierarchy base here because @processables may include both a parent job
+ # and its dependents, and we do not want to exclude those dependents from being processed.
+ ::Gitlab::Ci::ProcessableObjectHierarchy.new(
+ ::Ci::Processable.where(id: @processables.map(&:id))
+ ).base_and_descendants
end
def ordered_by_dag(jobs)
diff --git a/app/services/ci/runners/assign_runner_service.rb b/app/services/ci/runners/assign_runner_service.rb
index 290f945cc72..4e7b08bdd7a 100644
--- a/app/services/ci/runners/assign_runner_service.rb
+++ b/app/services/ci/runners/assign_runner_service.rb
@@ -17,6 +17,10 @@ module Ci
return ServiceResponse.error(message: 'user not allowed to assign runner', http_status: :forbidden)
end
+ unless @user.can?(:register_project_runners, @project)
+ return ServiceResponse.error(message: 'user not allowed to add runners to project', http_status: :forbidden)
+ end
+
if @runner.assign_to(@project, @user)
ServiceResponse.success
else
diff --git a/app/services/ci/runners/stale_managers_cleanup_service.rb b/app/services/ci/runners/stale_managers_cleanup_service.rb
index b39f7315bc6..e216d8ea3d6 100644
--- a/app/services/ci/runners/stale_managers_cleanup_service.rb
+++ b/app/services/ci/runners/stale_managers_cleanup_service.rb
@@ -4,29 +4,32 @@ module Ci
module Runners
class StaleManagersCleanupService
MAX_DELETIONS = 1000
+ SUB_BATCH_LIMIT = 100
def execute
- ServiceResponse.success(payload: {
- # the `stale` relationship can return duplicates, so we don't try to return a precise count here
- deleted_managers: delete_stale_runner_managers > 0
- })
+ ServiceResponse.success(payload: delete_stale_runner_managers)
end
private
def delete_stale_runner_managers
+ batch_counts = []
total_deleted_count = 0
loop do
- sub_batch_limit = [100, MAX_DELETIONS].min
+ sub_batch_limit = [SUB_BATCH_LIMIT, MAX_DELETIONS].min
- # delete_all discards part of the `stale` scope query, so we expliclitly wrap it with a SELECT as a workaround
+ # delete_all discards part of the `stale` scope query, so we explicitly wrap it with a SELECT as a workaround
deleted_count = Ci::RunnerManager.id_in(Ci::RunnerManager.stale.limit(sub_batch_limit)).delete_all
+ batch_counts << deleted_count
total_deleted_count += deleted_count
break if deleted_count == 0 || total_deleted_count >= MAX_DELETIONS
end
- total_deleted_count
+ {
+ total_deleted: total_deleted_count,
+ batch_counts: batch_counts
+ }
end
end
end
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
index 66a3cb04d98..efa9716d2c8 100644
--- a/app/services/clusters/agent_tokens/create_service.rb
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -4,6 +4,7 @@ module Clusters
module AgentTokens
class CreateService
ALLOWED_PARAMS = %i[agent_id description name].freeze
+ ACTIVE_TOKENS_LIMIT = 2
attr_reader :agent, :current_user, :params
@@ -15,6 +16,7 @@ module Clusters
def execute
return error_no_permissions unless current_user.can?(:create_cluster, agent.project)
+ return error_active_tokens_limit_reached if active_tokens_limit_reached?
token = ::Clusters::AgentToken.new(filtered_params.merge(agent_id: agent.id, created_by_user: current_user))
@@ -33,6 +35,16 @@ module Clusters
ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project'))
end
+ def error_active_tokens_limit_reached
+ ServiceResponse.error(message: s_('ClusterAgent|An agent can have only two active tokens at a time'))
+ end
+
+ def active_tokens_limit_reached?
+ return false unless Feature.enabled?(:cluster_agents_limit_tokens_created)
+
+ ::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute.count >= ACTIVE_TOKENS_LIMIT
+ end
+
def filtered_params
params.slice(*ALLOWED_PARAMS)
end
diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb
index 7e982bf7686..2a634c5ec71 100644
--- a/app/services/commits/cherry_pick_service.rb
+++ b/app/services/commits/cherry_pick_service.rb
@@ -2,9 +2,18 @@
module Commits
class CherryPickService < ChangeService
+ def initialize(*args)
+ super
+
+ @start_project = params[:target_project] || @project
+ @source_project = params[:source_project] || @project
+ end
+
def create_commit!
- commit_change(:cherry_pick).tap do |sha|
- track_mr_picking(sha)
+ Gitlab::Git::CrossRepo.new(@project.repository, @source_project.repository).execute(@commit.id) do
+ commit_change(:cherry_pick).tap do |sha|
+ track_mr_picking(sha)
+ end
end
end
diff --git a/app/services/concerns/search/filter.rb b/app/services/concerns/search/filter.rb
new file mode 100644
index 00000000000..e234edcfce4
--- /dev/null
+++ b/app/services/concerns/search/filter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Search
+ module Filter
+ private
+
+ def filters
+ { state: params[:state], confidential: params[:confidential], include_archived: params[:include_archived] }
+ end
+ end
+end
+
+Search::Filter.prepend_mod
diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb
index a0b4040cff7..bb43cab79bb 100644
--- a/app/services/concerns/update_repository_storage_methods.rb
+++ b/app/services/concerns/update_repository_storage_methods.rb
@@ -14,12 +14,16 @@ module UpdateRepositoryStorageMethods
end
def execute
- repository_storage_move.with_lock do
- return ServiceResponse.success unless repository_storage_move.scheduled? # rubocop:disable Cop/AvoidReturnFromBlocks
+ response = repository_storage_move.with_lock do
+ next ServiceResponse.success unless repository_storage_move.scheduled?
repository_storage_move.start!
+
+ nil
end
+ return response if response
+
mirror_repositories unless same_filesystem?
repository_storage_move.transaction do
diff --git a/app/services/database/mark_migration_service.rb b/app/services/database/mark_migration_service.rb
new file mode 100644
index 00000000000..aff10fa5f76
--- /dev/null
+++ b/app/services/database/mark_migration_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Database
+ class MarkMigrationService
+ def initialize(connection:, version:)
+ @connection = connection
+ @version = version
+ end
+
+ def execute
+ return error(reason: :not_found) unless migration.present?
+ return error(reason: :invalid) if all_versions.include?(migration.version)
+
+ if create_version(version)
+ ServiceResponse.success
+ else
+ error(reason: :invalid)
+ end
+ end
+
+ private
+
+ attr_reader :connection, :version
+
+ def migration
+ @migration ||= connection
+ .migration_context
+ .migrations
+ .find { |migration| migration.version == version }
+ end
+
+ def all_versions
+ all_executed_migrations.map(&:to_i)
+ end
+
+ def all_executed_migrations
+ sm = Arel::SelectManager.new(arel_table)
+ sm.project(arel_table[:version])
+ sm.order(arel_table[:version].asc) # rubocop: disable CodeReuse/ActiveRecord
+ connection.select_values(sm, "#{self.class} Load")
+ end
+
+ def create_version(version)
+ im = Arel::InsertManager.new
+ im.into(arel_table)
+ im.insert(arel_table[:version] => version)
+ connection.insert(im, "#{self.class} Create", :version, version)
+ end
+
+ def arel_table
+ @arel_table ||= Arel::Table.new(:schema_migrations)
+ end
+
+ def error(reason:)
+ ServiceResponse.error(message: 'error', reason: reason)
+ end
+ end
+end
diff --git a/app/services/environments/create_service.rb b/app/services/environments/create_service.rb
new file mode 100644
index 00000000000..760c8a6e306
--- /dev/null
+++ b/app/services/environments/create_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Environments
+ class CreateService < BaseService
+ ALLOWED_ATTRIBUTES = %i[name external_url tier cluster_agent].freeze
+
+ def execute
+ unless can?(current_user, :create_environment, project)
+ return ServiceResponse.error(
+ message: _('Unauthorized to create an environment'),
+ payload: { environment: nil }
+ )
+ end
+
+ if unauthorized_cluster_agent?
+ return ServiceResponse.error(
+ message: _('Unauthorized to access the cluster agent in this project'),
+ payload: { environment: nil })
+ end
+
+ environment = project.environments.create(**params.slice(*ALLOWED_ATTRIBUTES))
+
+ if environment.persisted?
+ ServiceResponse.success(payload: { environment: environment })
+ else
+ ServiceResponse.error(
+ message: environment.errors.full_messages,
+ payload: { environment: nil }
+ )
+ end
+ end
+
+ private
+
+ def unauthorized_cluster_agent?
+ return false unless params[:cluster_agent]
+
+ ::Clusters::Agents::Authorizations::UserAccess::Finder
+ .new(current_user, agent: params[:cluster_agent], project: project)
+ .execute
+ .empty?
+ end
+ end
+end
diff --git a/app/services/environments/destroy_service.rb b/app/services/environments/destroy_service.rb
new file mode 100644
index 00000000000..f1530489a40
--- /dev/null
+++ b/app/services/environments/destroy_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Environments
+ class DestroyService < BaseService
+ def execute(environment)
+ unless can?(current_user, :destroy_environment, environment)
+ return ServiceResponse.error(
+ message: 'Unauthorized to delete the environment'
+ )
+ end
+
+ environment.destroy
+
+ unless environment.destroyed?
+ return ServiceResponse.error(
+ message: 'Attemped to destroy the environment but failed'
+ )
+ end
+
+ ServiceResponse.success
+ end
+ end
+end
diff --git a/app/services/environments/update_service.rb b/app/services/environments/update_service.rb
new file mode 100644
index 00000000000..5eb4880ec4b
--- /dev/null
+++ b/app/services/environments/update_service.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Environments
+ class UpdateService < BaseService
+ ALLOWED_ATTRIBUTES = %i[external_url tier cluster_agent].freeze
+
+ def execute(environment)
+ unless can?(current_user, :update_environment, environment)
+ return ServiceResponse.error(
+ message: _('Unauthorized to update the environment'),
+ payload: { environment: environment }
+ )
+ end
+
+ if unauthorized_cluster_agent?
+ return ServiceResponse.error(
+ message: _('Unauthorized to access the cluster agent in this project'),
+ payload: { environment: environment })
+ end
+
+ if environment.update(**params.slice(*ALLOWED_ATTRIBUTES))
+ ServiceResponse.success(payload: { environment: environment })
+ else
+ ServiceResponse.error(
+ message: environment.errors.full_messages,
+ payload: { environment: environment }
+ )
+ end
+ end
+
+ private
+
+ def unauthorized_cluster_agent?
+ return false unless params[:cluster_agent]
+
+ ::Clusters::Agents::Authorizations::UserAccess::Finder
+ .new(current_user, agent: params[:cluster_agent], project: project)
+ .execute
+ .empty?
+ end
+ end
+end
diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb
deleted file mode 100644
index 8cb3793ba97..00000000000
--- a/app/services/error_tracking/collect_error_service.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-# frozen_string_literal: true
-
-module ErrorTracking
- class CollectErrorService < ::BaseService
- include Gitlab::Utils::StrongMemoize
-
- def execute
- error_repository.report_error(
- name: exception['type'],
- description: exception['value'],
- actor: actor,
- platform: event['platform'],
- occurred_at: timestamp,
- environment: event['environment'],
- level: event['level'],
- payload: event
- )
- end
-
- private
-
- def error_repository
- Gitlab::ErrorTracking::ErrorRepository.build(project)
- end
-
- def event
- @event ||= format_event(params[:event])
- end
-
- def format_event(event)
- # Some SDK send exception payload as Array. For exmple Go lang SDK.
- # We need to convert it to hash format we expect.
- if event['exception'].is_a?(Array)
- exception = event['exception']
- event['exception'] = { 'values' => exception }
- end
-
- event
- end
-
- def exception
- strong_memoize(:exception) do
- # Find the first exception that has a stacktrace since the first
- # exception may not provide adequate context (e.g. in the Go SDK).
- entries = event['exception']['values']
- entries.find { |x| x.key?('stacktrace') } || entries.first
- end
- end
-
- def stacktrace_frames
- strong_memoize(:stacktrace_frames) do
- exception.dig('stacktrace', 'frames')
- end
- end
-
- def actor
- return event['transaction'] if event['transaction'].present?
-
- # Some SDKs do not have a transaction attribute.
- # So we build it by combining function name and module name from
- # the last item in stacktrace.
- return unless stacktrace_frames.present?
-
- last_line = stacktrace_frames.last
-
- "#{last_line['function']}(#{last_line['module']})"
- end
-
- def timestamp
- return @timestamp if @timestamp
-
- @timestamp = (event['timestamp'] || Time.zone.now)
-
- # Some SDK send timestamp in numeric format like '1630945472.13'.
- if @timestamp.to_s =~ /\A\d+(\.\d+)?\z/
- @timestamp = Time.zone.at(@timestamp.to_f)
- end
-
- @timestamp
- end
- end
-end
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index 028906a0b43..834409bf3c4 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -39,9 +39,9 @@ module FeatureFlags
def created_strategy_message(strategy)
scopes = strategy.scopes
- .map { |scope| %Q("#{scope.environment_scope}") }
+ .map { |scope| %("#{scope.environment_scope}") }
.join(', ')
- %Q(Created strategy "#{strategy.name}" with scopes #{scopes}.)
+ %(Created strategy "#{strategy.name}" with scopes #{scopes}.)
end
def feature_flag_by_name
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 2ead2e2a113..31da099d078 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -4,6 +4,9 @@ module Git
class BranchHooksService < ::Git::BaseHooksService
extend ::Gitlab::Utils::Override
+ JIRA_SYNC_BATCH_SIZE = 20
+ JIRA_SYNC_BATCH_DELAY = 10.seconds
+
def execute
execute_branch_hooks
@@ -99,7 +102,6 @@ module Git
def branch_change_hooks
enqueue_process_commit_messages
enqueue_jira_connect_sync_messages
- enqueue_metrics_dashboard_sync
track_ci_config_change_event
end
@@ -107,13 +109,6 @@ module Git
project.repository.after_remove_branch(expire_cache: false)
end
- def enqueue_metrics_dashboard_sync
- return unless default_branch?
- return unless modified_file_types.include?(:metrics_dashboard)
-
- ::Metrics::Dashboard::SyncDashboardsWorker.perform_async(project.id)
- end
-
def track_ci_config_change_event
return unless ::ServicePing::ServicePingSettings.enabled?
return unless default_branch?
@@ -157,13 +152,34 @@ module Git
return unless project.jira_subscription_exists?
branch_to_sync = branch_name if Atlassian::JiraIssueKeyExtractors::Branch.has_keys?(project, branch_name)
- commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha)
-
- if branch_to_sync || commits_to_sync.any?
- JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync, Atlassian::JiraConnect::Client.generate_update_sequence_id)
+ commits_to_sync = filtered_commit_shas
+
+ return if branch_to_sync.nil? && commits_to_sync.empty?
+
+ if commits_to_sync.any? && Feature.enabled?(:batch_delay_jira_branch_sync_worker, project)
+ commits_to_sync.each_slice(JIRA_SYNC_BATCH_SIZE).with_index do |commits, i|
+ JiraConnect::SyncBranchWorker.perform_in(
+ JIRA_SYNC_BATCH_DELAY * i,
+ project.id,
+ branch_to_sync,
+ commits,
+ Atlassian::JiraConnect::Client.generate_update_sequence_id
+ )
+ end
+ else
+ JiraConnect::SyncBranchWorker.perform_async(
+ project.id,
+ branch_to_sync,
+ commits_to_sync,
+ Atlassian::JiraConnect::Client.generate_update_sequence_id
+ )
end
end
+ def filtered_commit_shas
+ limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha)
+ end
+
def signature_types
[
::CommitSignatures::GpgSignature,
diff --git a/app/services/google_cloud/enable_vision_ai_service.rb b/app/services/google_cloud/enable_vision_ai_service.rb
new file mode 100644
index 00000000000..f7adea706ed
--- /dev/null
+++ b/app/services/google_cloud/enable_vision_ai_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module GoogleCloud
+ class EnableVisionAiService < ::GoogleCloud::BaseService
+ def execute
+ gcp_project_ids = unique_gcp_project_ids
+
+ if gcp_project_ids.empty?
+ error("No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.")
+ else
+ gcp_project_ids.each do |gcp_project_id|
+ google_api_client.enable_vision_api(gcp_project_id)
+ end
+
+ success({ gcp_project_ids: gcp_project_ids })
+ end
+ end
+ end
+end
diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb
index 791be69f4d4..95de1fa21b7 100644
--- a/app/services/google_cloud/generate_pipeline_service.rb
+++ b/app/services/google_cloud/generate_pipeline_service.rb
@@ -4,6 +4,7 @@ module GoogleCloud
class GeneratePipelineService < ::GoogleCloud::BaseService
ACTION_DEPLOY_TO_CLOUD_RUN = 'DEPLOY_TO_CLOUD_RUN'
ACTION_DEPLOY_TO_CLOUD_STORAGE = 'DEPLOY_TO_CLOUD_STORAGE'
+ ACTION_VISION_AI_PIPELINE = 'VISION_AI_PIPELINE'
def execute
commit_attributes = generate_commit_attributes
@@ -53,6 +54,15 @@ module GoogleCloud
branch_name: branch_name,
start_branch: branch_name
}
+ when ACTION_VISION_AI_PIPELINE
+ branch_name = "vision-ai-pipeline-#{SecureRandom.hex(8)}"
+ {
+ commit_message: 'Enable Vision AI Pipeline',
+ file_path: '.gitlab-ci.yml',
+ file_content: pipeline_content('gcp/vision-ai.gitlab-ci.yml'),
+ branch_name: branch_name,
+ start_branch: branch_name
+ }
end
end
@@ -67,7 +77,11 @@ module GoogleCloud
def append_remote_include(gitlab_ci_yml, include_url)
stages = gitlab_ci_yml['stages'] || []
- gitlab_ci_yml['stages'] = (stages + %w[build test deploy]).uniq
+ gitlab_ci_yml['stages'] = if action == ACTION_VISION_AI_PIPELINE
+ (stages + %w[validate detect render]).uniq
+ else
+ (stages + %w[build test deploy]).uniq
+ end
includes = gitlab_ci_yml['include'] || []
includes = Array.wrap(includes)
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 1c8df157716..16454360ee2 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -69,7 +69,7 @@ module Groups
return false if group.root_ancestor == @new_parent_group.root_ancestor
return true if group.contacts.exists? && !current_user.can?(:admin_crm_contact, @new_parent_group.root_ancestor)
- return true if group.organizations.exists? && !current_user.can?(:admin_crm_organization, @new_parent_group.root_ancestor)
+ return true if group.crm_organizations.exists? && !current_user.can?(:admin_crm_organization, @new_parent_group.root_ancestor)
false
end
diff --git a/app/services/import_csv/base_service.rb b/app/services/import_csv/base_service.rb
index 70834b8a85a..cfaf3e831eb 100644
--- a/app/services/import_csv/base_service.rb
+++ b/app/services/import_csv/base_service.rb
@@ -103,16 +103,12 @@ module ImportCsv
strong_memoize_attr :detect_col_sep
def create_object(attributes)
- # 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
-
# default_params can be extracted into a method if we need
# to support creation of objects that belongs to groups.
default_params = { container: project,
current_user: user,
params: attributes,
- spam_params: spam_params }
+ perform_spam_check: false }
create_service = create_object_class.new(**default_params.merge(extra_create_service_params))
diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb
index a75c5d2e75c..fe0b41a3a31 100644
--- a/app/services/incident_management/incidents/create_service.rb
+++ b/app/services/incident_management/incidents/create_service.rb
@@ -25,7 +25,7 @@ module IncidentManagement
severity: severity,
alert_management_alerts: [alert].compact
},
- spam_params: nil
+ perform_spam_check: false
).execute
if alert
diff --git a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb
index 34af03640d3..e9d86e9228d 100644
--- a/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb
+++ b/app/services/integrations/slack_interactions/incident_management/incident_modal_submit_service.rb
@@ -22,7 +22,7 @@ module Integrations
container: project,
current_user: find_user.user,
params: incident_params,
- spam_params: nil
+ perform_spam_check: false
).execute
raise IssueCreateError, create_response.errors.to_sentence if create_response.error?
diff --git a/app/services/issuable/callbacks/base.rb b/app/services/issuable/callbacks/base.rb
index 3fabce2c949..368dd76c16c 100644
--- a/app/services/issuable/callbacks/base.rb
+++ b/app/services/issuable/callbacks/base.rb
@@ -12,6 +12,7 @@ module Issuable
end
def after_initialize; end
+ def before_update; end
def after_update_commit; end
def after_save_commit; end
diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb
index 261afb767bb..47770d101f9 100644
--- a/app/services/issuable/destroy_service.rb
+++ b/app/services/issuable/destroy_service.rb
@@ -8,11 +8,15 @@ module Issuable
end
def execute(issuable)
+ before_destroy(issuable)
after_destroy(issuable) if issuable.destroy
end
private
+ # overriden in EE
+ def before_destroy(issuable); end
+
def after_destroy(issuable)
delete_associated_records(issuable)
issuable.update_project_counter_caches
diff --git a/app/services/issuable/discussions_list_service.rb b/app/services/issuable/discussions_list_service.rb
index cb9271de11d..83efbf65b92 100644
--- a/app/services/issuable/discussions_list_service.rb
+++ b/app/services/issuable/discussions_list_service.rb
@@ -4,7 +4,6 @@
# System notes also have a discussion ID assigned including Synthetic system notes.
module Issuable
class DiscussionsListService
- include RendersNotes
include Gitlab::Utils::StrongMemoize
attr_reader :current_user, :issuable, :params
@@ -37,7 +36,9 @@ module Issuable
).execute(notes)
end
- notes = prepare_notes_for_rendering(notes)
+ # Here we assume all notes belong to the same project as the work item
+ project = notes.first&.project
+ notes = ::Preloaders::Projects::NotesPreloader.new(project, current_user).call(notes)
# we need to check the permission on every note, because some system notes for instance can have references to
# resources that some user do not have read access, so those notes are filtered out from the list of notes.
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index e9312bd6b31..3b007d4dba7 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -314,16 +314,19 @@ class IssuableBaseService < ::BaseContainerService
before_update(issuable)
- # Do not touch when saving the issuable if only changes position within a list. We should call
- # this method at this point to capture all possible changes.
- should_touch = update_timestamp?(issuable)
-
- issuable.updated_by = current_user if should_touch
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
update_project_counters = issuable.project && update_project_counter_caches?(issuable)
issuable_saved = issuable.with_transaction_returning_status do
+ @callbacks.each(&:before_update)
+
+ # Do not touch when saving the issuable if only changes position within a list. We should call
+ # this method at this point to capture all possible changes.
+ should_touch = update_timestamp?(issuable)
+
+ issuable.updated_by = current_user if should_touch
+
transaction_update(issuable, { save_with_touch: should_touch })
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index efe42fb29d5..f982d66eb08 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -124,6 +124,10 @@ module Issues
def update_project_counter_caches?(issue)
super || issue.confidential_changed?
end
+
+ def log_audit_event(issue, user, event_type, message)
+ # defined in EE
+ end
end
end
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
index c2a724254a7..8af44fb1e3c 100644
--- a/app/services/issues/clone_service.rb
+++ b/app/services/issues/clone_service.rb
@@ -68,9 +68,7 @@ module Issues
new_params.delete(:created_at)
new_params.delete(:updated_at)
- # 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
+ # spam checking is not necessary, as no new content is being created.
# Skip creation of system notes for existing attributes of the issue when cloning with notes.
# The system notes of the old issue are copied over so we don't want to end up with duplicate notes.
@@ -79,7 +77,7 @@ module Issues
container: target_project,
current_user: current_user,
params: new_params,
- spam_params: spam_params
+ perform_spam_check: false
).execute(skip_system_notes: with_notes)
raise CloneError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank?
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index e45033f2b91..f848a8db12a 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -28,6 +28,11 @@ module Issues
event_service.close_issue(issue, current_user)
create_note(issue, closed_via) if system_note
+ if current_user.project_bot?
+ log_audit_event(issue, current_user, "#{issue.issue_type}_closed_by_project_bot",
+ "Closed #{issue.issue_type.humanize(capitalize: false)} #{issue.title}")
+ end
+
closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit)
notification_service.async.close_issue(issue, current_user, { closed_via: closed_via }) if notifications
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index ba8f00d03d4..17b6866773e 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -9,14 +9,10 @@ module Issues
rate_limit key: :issues_create,
opts: { scope: [:project, :current_user, :external_author] }
- # 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(container:, spam_params:, current_user: nil, params: {}, build_service: nil)
+ def initialize(container:, current_user: nil, params: {}, build_service: nil, perform_spam_check: true)
@extra_params = params.delete(:extra_params) || {}
super(container: container, current_user: current_user, params: params)
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
@build_service = build_service ||
BuildService.new(container: project, current_user: current_user, params: params)
end
@@ -51,12 +47,7 @@ module Issues
end
def before_create(issue)
- Spam::SpamActionService.new(
- spammable: issue,
- spam_params: spam_params,
- user: current_user,
- action: :create
- ).execute
+ issue.check_for_spam(user: current_user, action: :create) if perform_spam_check
# current_user (defined in BaseService) is not available within run_after_commit block
user = current_user
@@ -109,7 +100,7 @@ module Issues
:create_issue
end
- attr_reader :spam_params, :extra_params
+ attr_reader :perform_spam_check, :extra_params
def create_timeline_event(issue)
return unless issue.work_item_type&.incident?
@@ -118,7 +109,7 @@ module Issues
end
def user_agent_detail_service
- UserAgentDetailService.new(spammable: @issue, spam_params: spam_params)
+ UserAgentDetailService.new(spammable: @issue, perform_spam_check: perform_spam_check)
end
def handle_add_related_issue(issue)
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index a2180dabdea..c1599ceef6e 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -90,9 +90,7 @@ module Issues
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
new_params = new_params.merge(rewritten_old_entity_attributes)
- # 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
+ # spam checking is not necessary, as no new content is being created.
# 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.
@@ -100,7 +98,7 @@ module Issues
container: @target_project,
current_user: @current_user,
params: new_params,
- spam_params: spam_params
+ perform_spam_check: false
).execute(skip_system_notes: true)
raise MoveError, create_result.errors.join(', ') if create_result.error? && create_result[:issue].blank?
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index f4d229ecec7..d71ba4e3414 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -7,6 +7,12 @@ module Issues
if issue.reopen
event_service.reopen_issue(issue, current_user)
+
+ if current_user.project_bot?
+ log_audit_event(issue, current_user, "#{issue.issue_type}_reopened_by_project_bot",
+ "Reopened #{issue.issue_type.humanize(capitalize: false)} #{issue.title}")
+ end
+
create_note(issue, 'reopened')
notification_service.async.reopen_issue(issue, current_user)
perform_incident_management_actions(issue)
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 201bf19b535..7ad56d5a755 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -2,12 +2,12 @@
module Issues
class UpdateService < Issues::BaseService
- # 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
+ # NOTE: For Issues::UpdateService, we default perform_spam_check to false, because spam_checking is not
+ # necessary in many cases, and we don't want to require every caller to explicitly pass it
# to disable spam checking.
- def initialize(container:, current_user: nil, params: {}, spam_params: nil)
+ def initialize(container:, current_user: nil, params: {}, perform_spam_check: false)
super(container: container, current_user: current_user, params: params)
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
end
def execute(issue)
@@ -26,14 +26,9 @@ module Issues
def before_update(issue, skip_spam_check: false)
change_work_item_type(issue)
- return if skip_spam_check
+ return if skip_spam_check || !perform_spam_check
- Spam::SpamActionService.new(
- spammable: issue,
- spam_params: spam_params,
- user: current_user,
- action: :update
- ).execute
+ issue.check_for_spam(user: current_user, action: :update)
end
def change_work_item_type(issue)
@@ -115,7 +110,14 @@ module Issues
private
- attr_reader :spam_params
+ attr_reader :perform_spam_check
+
+ override :after_update
+ def after_update(issue, _old_associations)
+ super
+
+ GraphqlTriggers.work_item_updated(issue)
+ end
def handle_date_changes(issue)
return unless issue.previous_changes.slice('due_date', 'start_date').any?
diff --git a/app/services/jira_connect_installations/update_service.rb b/app/services/jira_connect_installations/update_service.rb
index ff5b9671e2b..d0cf614a068 100644
--- a/app/services/jira_connect_installations/update_service.rb
+++ b/app/services/jira_connect_installations/update_service.rb
@@ -51,7 +51,7 @@ module JiraConnectInstallations
'Could not be installed on the instance. Network error'
end
- ServiceResponse.error(message: { instance_url: [message] })
+ ServiceResponse.error(message: message)
end
def update_error
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index f174778e12e..5c1ec5add73 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -7,7 +7,9 @@ module MergeRequests
def execute(merge_request)
merge_request.ensure_merge_request_diff
+ logger.info(**log_payload(merge_request, 'Executing hooks'))
execute_hooks(merge_request)
+ logger.info(**log_payload(merge_request, 'Executed hooks'))
prepare_for_mergeability(merge_request)
prepare_merge_request(merge_request)
@@ -17,7 +19,9 @@ module MergeRequests
private
def prepare_for_mergeability(merge_request)
+ logger.info(**log_payload(merge_request, 'Creating pipeline'))
create_pipeline_for(merge_request, current_user)
+ logger.info(**log_payload(merge_request, 'Pipeline created'))
merge_request.update_head_pipeline
check_mergeability(merge_request)
end
@@ -58,6 +62,17 @@ module MergeRequests
def mark_merge_request_as_prepared(merge_request)
merge_request.update!(prepared_at: Time.current)
end
+
+ def logger
+ @logger ||= Gitlab::AppLogger
+ end
+
+ def log_payload(merge_request, message)
+ Gitlab::ApplicationContext.current.merge(
+ merge_request_id: merge_request.id,
+ message: message
+ )
+ end
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 3a7b577d59a..b8853e8bcbc 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -334,7 +334,7 @@ module MergeRequests
strong_memoize(:issue_iid) do
@params_issue_iid || begin
id = if target_project.external_issue_tracker
- source_branch.match(target_project.external_issue_reference_pattern).try(:[], 0)
+ target_project.external_issue_reference_pattern.match(source_branch).try(:[], 0)
end
id || source_branch.match(/\A(\d+)-/).try(:[], 1)
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index da3a9652d69..62928e05a89 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -12,6 +12,7 @@ module MergeRequests
merge_request.allow_broken = true
if merge_request.close
+ expire_unapproved_key(merge_request)
create_event(merge_request)
merge_request_activity_counter.track_close_mr_action(user: current_user)
create_note(merge_request)
@@ -40,8 +41,14 @@ module MergeRequests
end
end
+ def expire_unapproved_key(merge_request)
+ nil
+ end
+
def trigger_merge_request_merge_status_updated(merge_request)
GraphqlTriggers.merge_request_merge_status_updated(merge_request)
end
end
end
+
+MergeRequests::CloseService.prepend_mod
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 39e1594d215..9135a80c883 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -41,6 +41,7 @@ module MergeRequests
# timeout, we do this before we attempt to save the merge request.
merge_request.skip_ensure_merge_request_diff = true
+ merge_request.check_for_spam(user: current_user, action: :create)
end
def set_projects!
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 10301774f96..5e41375e7a0 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -160,7 +160,7 @@ module MergeRequests
end
def handle_merge_error(log_message:, save_message_on_model: false)
- log_error("MergeService ERROR: #{merge_request_info} - #{log_message}")
+ log_error("MergeService ERROR: #{merge_request_info}:#{merge_status} - #{log_message}")
@merge_request.update(merge_error: log_message) if save_message_on_model
end
@@ -186,6 +186,10 @@ module MergeRequests
@merge_request_info ||= merge_request.to_reference(full: true)
end
+ def merge_status
+ @merge_status ||= @merge_request.merge_status
+ end
+
def source_matches?
# params-keys are symbols coming from the controller, but when they get
# loaded from the database they're strings
diff --git a/app/services/merge_requests/mergeability/logger.rb b/app/services/merge_requests/mergeability/logger.rb
index 88ef6d81eaa..612c79f0aae 100644
--- a/app/services/merge_requests/mergeability/logger.rb
+++ b/app/services/merge_requests/mergeability/logger.rb
@@ -22,8 +22,8 @@ module MergeRequests
result = yield
+ observe_result(mergeability_name, result)
observe("mergeability.#{mergeability_name}.duration_s", current_monotonic_time - op_started_at)
-
observe_sql_counters(mergeability_name, op_start_db_counters, current_db_counter_payload)
result
@@ -31,7 +31,13 @@ module MergeRequests
private
- attr_reader :destination, :merge_request
+ attr_reader :destination, :merge_request, :stored_result
+
+ def observe_result(name, result)
+ return unless result.respond_to?(:success?)
+
+ observe("mergeability.#{name}.successful", result.success?)
+ end
def observe(name, value)
observations[name.to_s].push(value)
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index d2247a6d4c1..b2e15cc7c7e 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -37,3 +37,5 @@ module MergeRequests
end
end
end
+
+MergeRequests::ReopenService.prepend_mod
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index aaed01403cb..598dbaf93a9 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -209,6 +209,11 @@ module MergeRequests
old_branch, new_branch)
end
+ override :before_update
+ def before_update(merge_request, skip_spam_check: false)
+ merge_request.check_for_spam(user: current_user, action: :update) unless skip_spam_check
+ end
+
override :handle_quick_actions
def handle_quick_actions(merge_request)
super
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 7dd6cd9a87c..fdab2a07990 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -21,6 +21,8 @@ module Notes
# only, there is no need be create a note!
execute_quick_actions(note) do |only_commands|
+ note.check_for_spam(action: :create, user: current_user) unless only_commands
+
note.run_after_commit do
# Finish the harder work in the background
NewNoteWorker.perform_async(note.id)
@@ -105,16 +107,10 @@ module Notes
def do_commands(note, update_params, message, command_names, only_commands)
return if quick_actions_service.commands_executed_count.to_i == 0
- if update_params.present?
- invalid_message = validate_commands(note, update_params)
-
- if invalid_message
- note.errors.add(:validation, invalid_message)
- message = invalid_message
- else
- quick_actions_service.apply_updates(update_params, note)
- note.commands_changes = update_params
- end
+ update_error = quick_actions_update_errors(note, update_params)
+ if update_error
+ note.errors.add(:validation, update_error)
+ message = update_error
end
# We must add the error after we call #save because errors are reset
@@ -127,6 +123,19 @@ module Notes
end
end
+ def quick_actions_update_errors(note, params)
+ return unless params.present?
+
+ invalid_message = validate_commands(note, params)
+ return invalid_message if invalid_message
+
+ service_response = quick_actions_service.apply_updates(params, note)
+ note.commands_changes = params
+ return if service_response.success?
+
+ service_response.message.join(', ')
+ end
+
def quick_action_options
{
merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 38f7a23ce29..cba7398ebc0 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -50,7 +50,21 @@ module Notes
update_params[:spend_time][:note_id] = note.id
end
- noteable_update_service(note, update_params).execute(note.noteable)
+ execute_update_service(note, update_params)
+ end
+
+ private
+
+ def execute_update_service(note, params)
+ service_response = noteable_update_service(note, params).execute(note.noteable)
+
+ service_errors = if service_response.respond_to?(:errors)
+ service_response.errors.full_messages
+ elsif service_response.respond_to?(:[]) && service_response[:status] == :error
+ service_response[:message]
+ end
+
+ service_errors.blank? ? ServiceResponse.success : ServiceResponse.error(message: service_errors)
end
def noteable_update_service(note, update_params)
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index e04891da7f8..52940281018 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -9,6 +9,8 @@ module Notes
note.assign_attributes(params)
+ return note unless note.valid?
+
track_note_edit_usage_for_issues(note) if note.for_issue?
track_note_edit_usage_for_merge_requests(note) if note.for_merge_request?
@@ -23,10 +25,7 @@ module Notes
note.note = content
end
- if note.note_changed?
- note.assign_attributes(last_edited_at: Time.current, updated_by: current_user)
- end
-
+ update_note(note, only_commands)
note.save
unless only_commands || note.for_personal_snippet?
@@ -45,7 +44,6 @@ module Notes
if only_commands
delete_note(note, message)
- note = nil
else
note.save
end
@@ -56,6 +54,13 @@ module Notes
private
+ def update_note(note, only_commands)
+ return unless note.note_changed?
+
+ note.assign_attributes(last_edited_at: Time.current, updated_by: current_user)
+ note.check_for_spam(action: :update, user: current_user) unless only_commands
+ end
+
def delete_note(note, message)
# We must add the error after we call #save because errors are reset
# when #save is called
diff --git a/app/services/object_storage/delete_stale_direct_uploads_service.rb b/app/services/object_storage/delete_stale_direct_uploads_service.rb
new file mode 100644
index 00000000000..e9560753fc4
--- /dev/null
+++ b/app/services/object_storage/delete_stale_direct_uploads_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module ObjectStorage
+ class DeleteStaleDirectUploadsService < BaseService
+ MAX_EXEC_DURATION = 250.seconds.freeze
+
+ def initialize; end
+
+ def execute
+ total_pending_entries = ObjectStorage::PendingDirectUpload.count
+ total_deleted_stale_entries = 0
+
+ timeout = false
+ start = Time.current
+
+ ObjectStorage::PendingDirectUpload.each do |pending_upload|
+ if pending_upload.stale?
+ pending_upload.delete
+ total_deleted_stale_entries += 1
+ end
+
+ if (Time.current - start) > MAX_EXEC_DURATION
+ timeout = true
+ break
+ end
+ end
+
+ success(
+ total_pending_entries: total_pending_entries,
+ total_deleted_stale_entries: total_deleted_stale_entries,
+ execution_timeout: timeout
+ )
+ end
+ end
+end
diff --git a/app/services/packages/cleanup/execute_policy_service.rb b/app/services/packages/cleanup/execute_policy_service.rb
index b432f6d0acb..891866bce5f 100644
--- a/app/services/packages/cleanup/execute_policy_service.rb
+++ b/app/services/packages/cleanup/execute_policy_service.rb
@@ -79,10 +79,9 @@ module Packages
end
def batch_deadline
- strong_memoize(:batch_deadline) do
- MAX_EXECUTION_TIME.from_now
- end
+ MAX_EXECUTION_TIME.from_now
end
+ strong_memoize_attr :batch_deadline
def response_success(timeout:)
ServiceResponse.success(
diff --git a/app/services/packages/cleanup/update_policy_service.rb b/app/services/packages/cleanup/update_policy_service.rb
index 6744accc007..911a060a18f 100644
--- a/app/services/packages/cleanup/update_policy_service.rb
+++ b/app/services/packages/cleanup/update_policy_service.rb
@@ -18,10 +18,9 @@ module Packages
private
def policy
- strong_memoize(:policy) do
- project.packages_cleanup_policy
- end
+ project.packages_cleanup_policy
end
+ strong_memoize_attr :policy
def allowed?
can?(current_user, :admin_package, project)
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
index 0f5429f667e..ae5933fad7c 100644
--- a/app/services/packages/composer/create_package_service.rb
+++ b/app/services/packages/composer/create_package_service.rb
@@ -27,10 +27,9 @@ module Packages
end
def composer_json
- strong_memoize(:composer_json) do
- ::Packages::Composer::ComposerJsonService.new(project, target).execute
- end
+ ::Packages::Composer::ComposerJsonService.new(project, target).execute
end
+ strong_memoize_attr :composer_json
def package_name
composer_json['name']
diff --git a/app/services/packages/debian/create_package_file_service.rb b/app/services/packages/debian/create_package_file_service.rb
index 24e40b5c986..2e9299a847c 100644
--- a/app/services/packages/debian/create_package_file_service.rb
+++ b/app/services/packages/debian/create_package_file_service.rb
@@ -14,7 +14,7 @@ module Packages
raise ArgumentError, "Invalid user" unless current_user.present?
# Debian package file are first uploaded to incoming with empty metadata,
- # and are moved later by Packages::Debian::ProcessChangesService
+ # and are moved later by Packages::Debian::ProcessPackageFileService
package_file = package.package_files.create!(
file: params[:file],
size: params[:file]&.size,
@@ -29,14 +29,12 @@ module Packages
}
)
- if params[:distribution].present? && params[:component].present?
+ if end_of_new_upload?
::Packages::Debian::ProcessPackageFileWorker.perform_async(
package_file.id,
params[:distribution],
params[:component]
)
- elsif params[:file_name].end_with? '.changes'
- ::Packages::Debian::ProcessChangesWorker.perform_async(package_file.id, current_user.id)
end
package_file
@@ -45,6 +43,10 @@ module Packages
private
attr_reader :package, :current_user, :params
+
+ def end_of_new_upload?
+ params[:distribution].present? || params[:file_name].end_with?('.changes')
+ end
end
end
end
diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb
index 43a4db5bdfc..5f06f46de58 100644
--- a/app/services/packages/debian/extract_changes_metadata_service.rb
+++ b/app/services/packages/debian/extract_changes_metadata_service.rb
@@ -26,10 +26,9 @@ module Packages
private
def metadata
- strong_memoize(:metadata) do
- ::Packages::Debian::ExtractMetadataService.new(@package_file).execute
- end
+ ::Packages::Debian::ExtractMetadataService.new(@package_file).execute
end
+ strong_memoize_attr :metadata
def file_type
metadata[:file_type]
@@ -40,20 +39,19 @@ module Packages
end
def files
- strong_memoize(:files) do
- raise ExtractionError, "is not a changes file" unless file_type == :changes
- raise ExtractionError, "Files field is missing" if fields['Files'].blank?
- raise ExtractionError, "Checksums-Sha1 field is missing" if fields['Checksums-Sha1'].blank?
- raise ExtractionError, "Checksums-Sha256 field is missing" if fields['Checksums-Sha256'].blank?
-
- init_entries_from_files
- entries_from_checksums_sha1
- entries_from_checksums_sha256
- entries_from_package_files
-
- @entries
- end
+ raise ExtractionError, "is not a changes file" unless file_type == :changes
+ raise ExtractionError, "Files field is missing" if fields['Files'].blank?
+ raise ExtractionError, "Checksums-Sha1 field is missing" if fields['Checksums-Sha1'].blank?
+ raise ExtractionError, "Checksums-Sha256 field is missing" if fields['Checksums-Sha256'].blank?
+
+ init_entries_from_files
+ entries_from_checksums_sha1
+ entries_from_checksums_sha256
+ entries_from_package_files
+
+ @entries
end
+ strong_memoize_attr :files
def init_entries_from_files
each_lines_for('Files') do |line|
@@ -101,12 +99,17 @@ module Packages
def entries_from_package_files
@entries.each do |filename, entry|
- entry.package_file = ::Packages::PackageFileFinder.new(@package_file.package, filename).execute!
+ entry.package_file = ::Packages::PackageFileFinder.new(incoming, filename).execute!
entry.validate!
rescue ActiveRecord::RecordNotFound
raise ExtractionError, "#{filename} is listed in Files but was not uploaded"
end
end
+
+ def incoming
+ @package_file.package.project.packages.debian_incoming_package!
+ end
+ strong_memoize_attr(:incoming)
end
end
end
diff --git a/app/services/packages/debian/generate_distribution_key_service.rb b/app/services/packages/debian/generate_distribution_key_service.rb
index 917965da58e..25c84955a52 100644
--- a/app/services/packages/debian/generate_distribution_key_service.rb
+++ b/app/services/packages/debian/generate_distribution_key_service.rb
@@ -43,10 +43,9 @@ module Packages
attr_reader :params
def passphrase
- strong_memoize(:passphrase) do
- params[:passphrase] || ::User.random_password
- end
+ params[:passphrase] || ::User.random_password
end
+ strong_memoize_attr :passphrase
def pinentry_script_content
escaped_passphrase = Shellwords.escape(passphrase)
@@ -90,7 +89,7 @@ module Packages
'Name-Email': params[:name_email] || Gitlab.config.gitlab.email_reply_to,
'Name-Comment': params[:name_comment] || 'GitLab Debian repository automatic signing key',
'Expire-Date': params[:expire_date] || 0,
- 'Passphrase': passphrase
+ Passphrase: passphrase
}.map { |k, v| "#{k}: #{v}\n" }.join +
'</GnupgKeyParms>'
end
diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb
index d69f6eb1511..9feb860ae87 100644
--- a/app/services/packages/debian/generate_distribution_service.rb
+++ b/app/services/packages/debian/generate_distribution_service.rb
@@ -213,10 +213,9 @@ module Packages
end
def release_content
- strong_memoize(:release_content) do
- release_header + release_sums
- end
+ release_header + release_sums
end
+ strong_memoize_attr :release_content
def release_header
[
@@ -235,10 +234,9 @@ module Packages
end
def release_date
- strong_memoize(:release_date) do
- Time.now.utc
- end
+ Time.now.utc
end
+ strong_memoize_attr :release_date
def release_sums
# NB: MD5Sum was removed for FIPS compliance
diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb
index 129f2e5c9bc..eb88e7c9b59 100644
--- a/app/services/packages/debian/process_changes_service.rb
+++ b/app/services/packages/debian/process_changes_service.rb
@@ -76,10 +76,9 @@ module Packages
end
def metadata
- strong_memoize(:metadata) do
- ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute
- end
+ ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute
end
+ strong_memoize_attr :metadata
def files
metadata[:files]
@@ -90,16 +89,15 @@ module Packages
end
def package
- strong_memoize(:package) do
- params = {
- 'name': metadata[:fields]['Source'],
- 'version': metadata[:fields]['Version'],
- 'distribution_name': metadata[:fields]['Distribution']
- }
- response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute
- response.payload[:package]
- end
+ params = {
+ name: metadata[:fields]['Source'],
+ version: metadata[:fields]['Version'],
+ distribution_name: metadata[:fields]['Distribution']
+ }
+ response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute
+ response.payload[:package]
end
+ strong_memoize_attr :package
# used by ExclusiveLeaseGuard
def lease_key
diff --git a/app/services/packages/debian/process_package_file_service.rb b/app/services/packages/debian/process_package_file_service.rb
index f4fcd3a563c..684192f6006 100644
--- a/app/services/packages/debian/process_package_file_service.rb
+++ b/app/services/packages/debian/process_package_file_service.rb
@@ -10,6 +10,8 @@ module Packages
# used by ExclusiveLeaseGuard
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
+ SIMPLE_DEB_FILE_TYPES = %i[deb udeb ddeb].freeze
+
def initialize(package_file, distribution_name, component_name)
@package_file = package_file
@distribution_name = distribution_name
@@ -22,9 +24,10 @@ module Packages
validate!
try_obtain_lease do
- package.transaction do
+ distribution.transaction do
rename_package_and_set_version
update_package
+ update_files_metadata if changes_file?
update_file_metadata
cleanup_temp_package
end
@@ -36,28 +39,61 @@ module Packages
private
def validate!
- raise ArgumentError, 'missing distribution name' unless @distribution_name.present?
- raise ArgumentError, 'missing component name' unless @component_name.present?
raise ArgumentError, 'package file without Debian metadata' unless @package_file.debian_file_metadatum
raise ArgumentError, 'already processed package file' unless @package_file.debian_file_metadatum.unknown?
- if file_metadata[:file_type] == :deb || file_metadata[:file_type] == :udeb || file_metadata[:file_type] == :ddeb
- return
- end
+ changes_file? ? validate_changes_file! : validate_package_file!
+ end
+
+ def changes_file?
+ @package_file.file_name.end_with?('.changes')
+ end
+
+ def validate_changes_file!
+ raise ArgumentError, 'unwanted distribution name' unless @distribution_name.nil?
+ raise ArgumentError, 'unwanted component name' unless @component_name.nil?
+ raise ArgumentError, 'missing Source field' unless file_metadata.dig(:fields, 'Source').present?
+ raise ArgumentError, 'missing Version field' unless file_metadata.dig(:fields, 'Version').present?
+ raise ArgumentError, 'missing Distribution field' unless file_metadata.dig(:fields, 'Distribution').present?
+ end
+
+ def validate_package_file!
+ raise ArgumentError, 'missing distribution name' unless @distribution_name.present?
+ raise ArgumentError, 'missing component name' unless @component_name.present?
+
+ return if SIMPLE_DEB_FILE_TYPES.include?(file_metadata[:file_type])
raise ArgumentError, "invalid package file type: #{file_metadata[:file_type]}"
end
def file_metadata
- ::Packages::Debian::ExtractMetadataService.new(@package_file).execute
+ metadata_service_class.new(@package_file).execute
end
strong_memoize_attr :file_metadata
+ def metadata_service_class
+ changes_file? ? ::Packages::Debian::ExtractChangesMetadataService : ::Packages::Debian::ExtractMetadataService
+ end
+
+ def distribution
+ Packages::Debian::DistributionsFinder.new(
+ @package_file.package.project,
+ codename_or_suite: package_distribution
+ ).execute.last!
+ end
+ strong_memoize_attr :distribution
+
+ def package_distribution
+ return file_metadata[:fields]['Distribution'] if changes_file?
+
+ @distribution_name
+ end
+
def package
packages = temp_package.project
.packages
.existing_debian_packages_with(name: package_name, version: package_version)
- package = packages.with_debian_codename_or_suite(@distribution_name)
+ package = packages.with_debian_codename_or_suite(package_distribution)
.first
unless package
@@ -79,10 +115,14 @@ module Packages
strong_memoize_attr :temp_package
def package_name
+ return file_metadata[:fields]['Source'] if changes_file?
+
package_name_and_version[0]
end
def package_version
+ return file_metadata[:fields]['Version'] if changes_file?
+
package_name_and_version[1]
end
@@ -121,13 +161,24 @@ module Packages
package.id == temp_package.id
end
- def distribution
- Packages::Debian::DistributionsFinder.new(
- @package_file.package.project,
- codename_or_suite: @distribution_name
- ).execute.last!
+ def update_files_metadata
+ file_metadata[:files].each do |_, entry|
+ file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute
+
+ ::Packages::UpdatePackageFileService.new(entry.package_file, package_id: package.id)
+ .execute
+
+ # Force reload from database, as package has changed
+ entry.package_file.reload_package
+
+ entry.package_file.debian_file_metadatum.update!(
+ file_type: file_metadata[:file_type],
+ component: entry.component,
+ architecture: file_metadata[:architecture],
+ fields: file_metadata[:fields]
+ )
+ end
end
- strong_memoize_attr :distribution
def update_file_metadata
::Packages::UpdatePackageFileService.new(@package_file, package_id: package.id)
@@ -150,7 +201,7 @@ module Packages
# used by ExclusiveLeaseGuard
def lease_key
- "packages:debian:process_package_file_service:package_file:#{@package_file.id}"
+ "packages:debian:process_package_file_service:#{temp_package.project_id}_#{package_name}_#{package_version}"
end
# used by ExclusiveLeaseGuard
diff --git a/app/services/packages/helm/process_file_service.rb b/app/services/packages/helm/process_file_service.rb
index f53c63d2b01..219f3d8c781 100644
--- a/app/services/packages/helm/process_file_service.rb
+++ b/app/services/packages/helm/process_file_service.rb
@@ -57,28 +57,25 @@ module Packages
end
def temp_package
- strong_memoize(:temp_package) do
- package_file.package
- end
+ package_file.package
end
+ strong_memoize_attr :temp_package
def package
- strong_memoize(:package) do
- project_packages = package_file.package.project.packages
- package = project_packages.with_package_type(:helm)
- .with_name(metadata['name'])
- .with_version(metadata['version'])
- .not_pending_destruction
- .last
- package || temp_package
- end
+ project_packages = package_file.package.project.packages
+ package = project_packages.with_package_type(:helm)
+ .with_name(metadata['name'])
+ .with_version(metadata['version'])
+ .not_pending_destruction
+ .last
+ package || temp_package
end
+ strong_memoize_attr :package
def metadata
- strong_memoize(:metadata) do
- ::Packages::Helm::ExtractFileMetadataService.new(package_file).execute
- end
+ ::Packages::Helm::ExtractFileMetadataService.new(package_file).execute
end
+ strong_memoize_attr :metadata
def file_name
"#{metadata['name']}-#{metadata['version']}.tgz"
diff --git a/app/services/packages/maven/metadata/base_create_xml_service.rb b/app/services/packages/maven/metadata/base_create_xml_service.rb
index 3b0d93e1dfb..d67d5a21a91 100644
--- a/app/services/packages/maven/metadata/base_create_xml_service.rb
+++ b/app/services/packages/maven/metadata/base_create_xml_service.rb
@@ -19,12 +19,11 @@ module Packages
attr_reader :logger
def xml_doc
- strong_memoize(:xml_doc) do
- Nokogiri::XML(@metadata_content) do |config|
- config.default_xml.noblanks
- end
+ Nokogiri::XML(@metadata_content) do |config|
+ config.default_xml.noblanks
end
end
+ strong_memoize_attr :xml_doc
def xml_node(name, content)
xml_doc.create_element(name).tap { |e| e.content = content }
diff --git a/app/services/packages/maven/metadata/create_plugins_xml_service.rb b/app/services/packages/maven/metadata/create_plugins_xml_service.rb
index 707a8c577ba..e99a72bc0ab 100644
--- a/app/services/packages/maven/metadata/create_plugins_xml_service.rb
+++ b/app/services/packages/maven/metadata/create_plugins_xml_service.rb
@@ -40,37 +40,34 @@ module Packages
end
def plugins_xml_node
- strong_memoize(:plugins_xml_node) do
- xml_doc.xpath(XPATH_PLUGINS)
+ xml_doc.xpath(XPATH_PLUGINS)
.first
- end
end
+ strong_memoize_attr :plugins_xml_node
def plugin_artifact_ids_from_xml
- strong_memoize(:plugin_artifact_ids_from_xml) do
- plugins_xml_node.xpath(XPATH_PLUGIN_ARTIFACT_ID)
+ plugins_xml_node.xpath(XPATH_PLUGIN_ARTIFACT_ID)
.map(&:content)
- end
end
+ strong_memoize_attr :plugin_artifact_ids_from_xml
def plugin_artifact_ids_from_database
- strong_memoize(:plugin_artifact_ids_from_database) do
- package_names = plugin_artifact_ids_from_xml.map do |artifact_id|
- "#{@package.name}/#{artifact_id}"
- end
-
- packages = @package.project.packages
- .maven
- .displayable
- .with_name(package_names)
- .has_version
-
- ::Packages::Maven::Metadatum.for_package_ids(packages.select(:id))
- .order_created
- .pluck_app_name
- .uniq
+ package_names = plugin_artifact_ids_from_xml.map do |artifact_id|
+ "#{@package.name}/#{artifact_id}"
end
+
+ packages = @package.project.packages
+ .maven
+ .displayable
+ .with_name(package_names)
+ .has_version
+
+ ::Packages::Maven::Metadatum.for_package_ids(packages.select(:id))
+ .order_created
+ .pluck_app_name
+ .uniq
end
+ strong_memoize_attr :plugin_artifact_ids_from_database
def plugin_node_for(artifact_id)
xml_doc.create_element('plugin').tap do |plugin_node|
diff --git a/app/services/packages/maven/metadata/create_versions_xml_service.rb b/app/services/packages/maven/metadata/create_versions_xml_service.rb
index c2ac7fea703..966540bcba2 100644
--- a/app/services/packages/maven/metadata/create_versions_xml_service.rb
+++ b/app/services/packages/maven/metadata/create_versions_xml_service.rb
@@ -91,49 +91,43 @@ module Packages
end
def versioning_xml_node
- strong_memoize(:versioning_xml_node) do
- xml_doc.xpath(XPATH_VERSIONING).first
- end
+ xml_doc.xpath(XPATH_VERSIONING).first
end
+ strong_memoize_attr :versioning_xml_node
def versions_xml_node
- strong_memoize(:versions_xml_node) do
- versioning_xml_node&.xpath(XPATH_VERSIONS)
+ versioning_xml_node&.xpath(XPATH_VERSIONS)
&.first
- end
end
+ strong_memoize_attr :versions_xml_node
def version_xml_nodes
versions_xml_node&.xpath(XPATH_VERSION)
end
def latest_xml_node
- strong_memoize(:latest_xml_node) do
- versioning_xml_node&.xpath(XPATH_LATEST)
+ versioning_xml_node&.xpath(XPATH_LATEST)
&.first
- end
end
+ strong_memoize_attr :latest_xml_node
def release_xml_node
- strong_memoize(:release_xml_node) do
- versioning_xml_node&.xpath(XPATH_RELEASE)
+ versioning_xml_node&.xpath(XPATH_RELEASE)
&.first
- end
end
+ strong_memoize_attr :release_xml_node
def last_updated_xml_node
- strong_memoize(:last_updated_xml_mode) do
- versioning_xml_node.xpath(XPATH_LAST_UPDATED)
+ versioning_xml_node.xpath(XPATH_LAST_UPDATED)
.first
- end
end
+ strong_memoize_attr :last_updated_xml_node
def versions_from_xml
- strong_memoize(:versions_from_xml) do
- versions_xml_node.xpath(XPATH_VERSION)
+ versions_xml_node.xpath(XPATH_VERSION)
.map(&:text)
- end
end
+ strong_memoize_attr :versions_from_xml
def latest_from_xml
latest_xml_node&.text
@@ -144,27 +138,25 @@ module Packages
end
def versions_from_database
- strong_memoize(:versions_from_database) do
- @package.project.packages
+ @package.project.packages
.maven
.displayable
.with_name(@package.name)
.has_version
.order_created
.pluck_versions
- end
end
+ strong_memoize_attr :versions_from_database
def latest_from_database
versions_from_database.last
end
def release_from_database
- strong_memoize(:release_from_database) do
- non_snapshot_versions_from_database = versions_from_database.reject { |v| v.ends_with?('SNAPSHOT') }
- non_snapshot_versions_from_database.last
- end
+ non_snapshot_versions_from_database = versions_from_database.reject { |v| v.ends_with?('SNAPSHOT') }
+ non_snapshot_versions_from_database.last
end
+ strong_memoize_attr :release_from_database
def log_malformed_content(reason)
logger.warn(
diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb
index dacf6750412..14196f090dd 100644
--- a/app/services/packages/maven/metadata/sync_service.rb
+++ b/app/services/packages/maven/metadata/sync_service.rb
@@ -70,25 +70,22 @@ module Packages
end
def metadata_package_file_for_versions
- strong_memoize(:metadata_file_for_versions) do
- metadata_package_file_for(versionless_package_for_versions)
- end
+ metadata_package_file_for(versionless_package_for_versions)
end
+ strong_memoize_attr :metadata_package_file_for_versions
def versionless_package_for_versions
- strong_memoize(:versionless_package_for_versions) do
- versionless_package_named(package_name)
- end
+ versionless_package_named(package_name)
end
+ strong_memoize_attr :versionless_package_for_versions
def metadata_package_file_for_plugins
- strong_memoize(:metadata_package_file_for_plugins) do
- pkg_name = package_name_for_plugins
- next unless pkg_name
+ pkg_name = package_name_for_plugins
+ return unless pkg_name
- metadata_package_file_for(versionless_package_named(package_name_for_plugins))
- end
+ metadata_package_file_for(versionless_package_named(package_name_for_plugins))
end
+ strong_memoize_attr :metadata_package_file_for_plugins
def metadata_package_file_for(package)
return unless package
diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb
new file mode 100644
index 00000000000..574f70940fc
--- /dev/null
+++ b/app/services/packages/ml_model/create_package_file_service.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Packages
+ module MlModel
+ class CreatePackageFileService < BaseService
+ def execute
+ ::Packages::Package.transaction do
+ create_package_file(find_or_create_package)
+ end
+ end
+
+ private
+
+ def find_or_create_package
+ package_params = {
+ name: params[:package_name],
+ version: params[:package_version],
+ build: params[:build],
+ status: params[:status]
+ }
+
+ package = ::Packages::MlModel::FindOrCreatePackageService
+ .new(project, current_user, package_params)
+ .execute
+
+ package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status
+
+ package.create_build_infos!(params[:build])
+
+ package
+ end
+
+ def create_package_file(package)
+ file_params = {
+ file: params[:file],
+ size: params[:file].size,
+ file_sha256: params[:file].sha256,
+ file_name: params[:file_name],
+ build: params[:build]
+ }
+
+ ::Packages::CreatePackageFileService.new(package, file_params).execute
+ end
+ end
+ end
+end
diff --git a/app/services/packages/ml_model/find_or_create_package_service.rb b/app/services/packages/ml_model/find_or_create_package_service.rb
new file mode 100644
index 00000000000..cab99e1b008
--- /dev/null
+++ b/app/services/packages/ml_model/find_or_create_package_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Packages
+ module MlModel
+ class FindOrCreatePackageService < ::Packages::CreatePackageService
+ def execute
+ find_or_create_package!(::Packages::Package.package_types['ml_model'])
+ end
+ end
+ end
+end
diff --git a/app/services/packages/npm/create_metadata_cache_service.rb b/app/services/packages/npm/create_metadata_cache_service.rb
index 1cc5f7f34e7..75cff5c5453 100644
--- a/app/services/packages/npm/create_metadata_cache_service.rb
+++ b/app/services/packages/npm/create_metadata_cache_service.rb
@@ -9,10 +9,9 @@ module Packages
# used by ExclusiveLeaseGuard
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
- def initialize(project, package_name, packages)
+ def initialize(project, package_name)
@project = project
@package_name = package_name
- @packages = packages
end
def execute
@@ -28,13 +27,19 @@ module Packages
private
- attr_reader :package_name, :packages, :project
+ attr_reader :package_name, :project
def metadata_content
metadata.payload.to_json
end
strong_memoize_attr :metadata_content
+ def packages
+ ::Packages::Npm::PackageFinder
+ .new(package_name, project: project)
+ .execute
+ end
+
def metadata
Packages::Npm::GenerateMetadataService.new(package_name, packages).execute
end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index c71ae060dd9..2c578760cc5 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -61,10 +61,9 @@ module Packages
end
def version
- strong_memoize(:version) do
- params[:versions].each_key.first
- end
+ params[:versions].each_key.first
end
+ strong_memoize_attr :version
def version_data
params[:versions][version]
@@ -79,30 +78,27 @@ module Packages
end
def package_file_name
- strong_memoize(:package_file_name) do
- "#{name}-#{version}.tgz"
- end
+ "#{name}-#{version}.tgz"
end
+ strong_memoize_attr :package_file_name
def attachment
- strong_memoize(:attachment) do
- params['_attachments'][package_file_name]
- end
+ params['_attachments'][package_file_name]
end
+ strong_memoize_attr :attachment
# TODO (technical debt): Extract the package size calculation to its own component and unit test it separately.
def calculated_package_file_size
- strong_memoize(:calculated_package_file_size) do
- # This calculation is based on:
- # 1. 4 chars in a Base64 encoded string are 3 bytes in the original string. Meaning 1 char is 0.75 bytes.
- # 2. The encoded string may have 1 or 2 extra '=' chars used for padding. Each padding char means 1 byte less in the original string.
- # Reference:
- # - https://blog.aaronlenoir.com/2017/11/10/get-original-length-from-base-64-string/
- # - https://en.wikipedia.org/wiki/Base64#Decoding_Base64_with_padding
- encoded_data = attachment['data']
- ((encoded_data.length * 0.75) - encoded_data[-2..].count('=')).to_i
- end
+ # This calculation is based on:
+ # 1. 4 chars in a Base64 encoded string are 3 bytes in the original string. Meaning 1 char is 0.75 bytes.
+ # 2. The encoded string may have 1 or 2 extra '=' chars used for padding. Each padding char means 1 byte less in the original string.
+ # Reference:
+ # - https://blog.aaronlenoir.com/2017/11/10/get-original-length-from-base-64-string/
+ # - https://en.wikipedia.org/wiki/Base64#Decoding_Base64_with_padding
+ encoded_data = attachment['data']
+ ((encoded_data.length * 0.75) - encoded_data[-2..].count('=')).to_i
end
+ strong_memoize_attr :calculated_package_file_size
def file_params
{
@@ -134,29 +130,26 @@ module Packages
end
def field_sizes
- strong_memoize(:field_sizes) do
- package_json.transform_values do |value|
- value.to_s.size
- end
+ package_json.transform_values do |value|
+ value.to_s.size
end
end
+ strong_memoize_attr :field_sizes
def filtered_field_sizes
- strong_memoize(:filtered_field_sizes) do
- field_sizes.select do |_, size|
- size >= ::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING
- end
+ field_sizes.select do |_, size|
+ size >= ::Packages::Npm::Metadatum::MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING
end
end
+ strong_memoize_attr :filtered_field_sizes
def largest_fields
- strong_memoize(:largest_fields) do
- field_sizes
+ field_sizes
.sort_by { |a| a[1] }
.reverse[0..::Packages::Npm::Metadatum::NUM_FIELDS_FOR_ERROR_TRACKING - 1]
.to_h
- end
end
+ strong_memoize_attr :largest_fields
def field_sizes_for_error_tracking
filtered_field_sizes.empty? ? largest_fields : filtered_field_sizes
diff --git a/app/services/packages/npm/create_tag_service.rb b/app/services/packages/npm/create_tag_service.rb
index 82974d0ca4b..e212b37c9ba 100644
--- a/app/services/packages/npm/create_tag_service.rb
+++ b/app/services/packages/npm/create_tag_service.rb
@@ -23,12 +23,11 @@ module Packages
private
def existing_tag
- strong_memoize(:existing_tag) do
- Packages::TagsFinder
+ Packages::TagsFinder
.new(package.project, package.name, package_type: package.package_type)
.find_by_name(tag_name)
- end
end
+ strong_memoize_attr :existing_tag
end
end
end
diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb
index 02086b2a282..5c60a2912ae 100644
--- a/app/services/packages/nuget/metadata_extraction_service.rb
+++ b/app/services/packages/nuget/metadata_extraction_service.rb
@@ -7,18 +7,22 @@ module Packages
ExtractionError = Class.new(StandardError)
+ ROOT_XPATH = '//xmlns:package/xmlns:metadata/xmlns'
+
XPATHS = {
- package_name: '//xmlns:package/xmlns:metadata/xmlns:id',
- package_version: '//xmlns:package/xmlns:metadata/xmlns:version',
- license_url: '//xmlns:package/xmlns:metadata/xmlns:licenseUrl',
- project_url: '//xmlns:package/xmlns:metadata/xmlns:projectUrl',
- icon_url: '//xmlns:package/xmlns:metadata/xmlns:iconUrl'
+ package_name: "#{ROOT_XPATH}:id",
+ package_version: "#{ROOT_XPATH}:version",
+ authors: "#{ROOT_XPATH}:authors",
+ description: "#{ROOT_XPATH}:description",
+ license_url: "#{ROOT_XPATH}:licenseUrl",
+ project_url: "#{ROOT_XPATH}:projectUrl",
+ icon_url: "#{ROOT_XPATH}:iconUrl"
}.freeze
- 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'
+ XPATH_DEPENDENCIES = "#{ROOT_XPATH}:dependencies/xmlns:dependency".freeze
+ XPATH_DEPENDENCY_GROUPS = "#{ROOT_XPATH}:dependencies/xmlns:group".freeze
+ XPATH_TAGS = "#{ROOT_XPATH}:tags".freeze
+ XPATH_PACKAGE_TYPES = "#{ROOT_XPATH}:packageTypes/xmlns:packageType".freeze
MAX_FILE_SIZE = 4.megabytes.freeze
@@ -35,14 +39,9 @@ module Packages
private
def package_file
- strong_memoize(:package_file) do
- ::Packages::PackageFile.find_by_id(@package_file_id)
- end
- end
-
- def project
- package_file.package.project
+ ::Packages::PackageFile.find_by_id(@package_file_id)
end
+ strong_memoize_attr :package_file
def valid_package_file?
package_file &&
diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb
index fea424b3aa8..7d1585f8903 100644
--- a/app/services/packages/nuget/search_service.rb
+++ b/app/services/packages/nuget/search_service.rb
@@ -89,17 +89,16 @@ module Packages
end
def base_matching_package_names
- strong_memoize(:base_matching_package_names) do
- # rubocop: disable CodeReuse/ActiveRecord
- pkgs = nuget_packages.order_name
+ # rubocop: disable CodeReuse/ActiveRecord
+ pkgs = nuget_packages.order_name
.select_distinct_name
.where(project_id: project_ids)
- pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions?
- pkgs = pkgs.search_by_name(@search_term) if @search_term.present?
- pkgs
- # rubocop: enable CodeReuse/ActiveRecord
- end
+ pkgs = pkgs.without_version_like(PRE_RELEASE_VERSION_MATCHING_TERM) unless include_prerelease_versions?
+ pkgs = pkgs.search_by_name(@search_term) if @search_term.present?
+ pkgs
+ # rubocop: enable CodeReuse/ActiveRecord
end
+ strong_memoize_attr :base_matching_package_names
def nuget_packages
Packages::Package.nuget
@@ -111,11 +110,10 @@ module Packages
def project_ids_cte
return unless use_project_ids_cte?
- strong_memoize(:project_ids_cte) do
- query = projects_visible_to_user(@current_user, within_group: @project_or_group)
- Gitlab::SQL::CTE.new(:project_ids, query.select(:id))
- end
+ query = projects_visible_to_user(@current_user, within_group: @project_or_group)
+ Gitlab::SQL::CTE.new(:project_ids, query.select(:id))
end
+ strong_memoize_attr :project_ids_cte
def project_ids
return @project_or_group.id if project?
diff --git a/app/services/packages/nuget/sync_metadatum_service.rb b/app/services/packages/nuget/sync_metadatum_service.rb
index ca9cc4d5b78..189b972c156 100644
--- a/app/services/packages/nuget/sync_metadatum_service.rb
+++ b/app/services/packages/nuget/sync_metadatum_service.rb
@@ -15,6 +15,8 @@ module Packages
metadatum.destroy! if metadatum.persisted?
else
metadatum.update!(
+ authors: authors,
+ description: description,
license_url: license_url,
project_url: project_url,
icon_url: icon_url
@@ -24,26 +26,57 @@ module Packages
private
+ attr_reader :package, :metadata
+
def metadatum
- strong_memoize(:metadatum) do
- @package.nuget_metadatum || @package.build_nuget_metadatum
- end
+ package.nuget_metadatum || package.build_nuget_metadatum
end
+ strong_memoize_attr :metadatum
def blank_metadata?
- project_url.blank? && license_url.blank? && icon_url.blank?
+ [authors, description, project_url, license_url, icon_url].all?(&:blank?)
+ end
+
+ def authors
+ truncate_value(:authors, ::Packages::Nuget::Metadatum::MAX_AUTHORS_LENGTH)
end
+ strong_memoize_attr :authors
+
+ def description
+ truncate_value(:description, ::Packages::Nuget::Metadatum::MAX_DESCRIPTION_LENGTH)
+ end
+ strong_memoize_attr :description
def project_url
- @metadata[:project_url]
+ metadata[:project_url]
end
def license_url
- @metadata[:license_url]
+ metadata[:license_url]
end
def icon_url
- @metadata[:icon_url]
+ metadata[:icon_url]
+ end
+
+ def truncate_value(field, max_length)
+ return unless metadata[field]
+
+ if metadata[field].size > max_length
+ log_info("#{field.capitalize} is too long (maximum is #{max_length} characters)", field)
+ end
+
+ metadata[field].truncate(max_length)
+ end
+
+ def log_info(message, field)
+ Gitlab::AppLogger.info(
+ class: self.class.name,
+ message: message,
+ package_id: package.id,
+ project_id: package.project_id,
+ field => metadata[field]
+ )
end
end
end
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 5456ad4cad7..8e2679db31b 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -9,6 +9,9 @@ module Packages
# used by ExclusiveLeaseGuard
DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze
SYMBOL_PACKAGE_IDENTIFIER = 'SymbolsPackage'
+ INVALID_METADATA_ERROR_MESSAGE = 'package name, version, authors and/or description not found in metadata'
+ INVALID_METADATA_ERROR_SYMBOL_MESSAGE = 'package name, version and/or description not found in metadata'
+ MISSING_MATCHING_PACKAGE_ERROR_MESSAGE = 'symbol package is invalid, matching package does not exist'
InvalidMetadataError = Class.new(StandardError)
@@ -17,7 +20,10 @@ module Packages
end
def execute
- raise InvalidMetadataError, 'package name and/or package version not found in metadata' unless valid_metadata?
+ unless valid_metadata?
+ error_message = symbol_package? ? INVALID_METADATA_ERROR_SYMBOL_MESSAGE : INVALID_METADATA_ERROR_MESSAGE
+ raise InvalidMetadataError, error_message
+ end
try_obtain_lease do
@package_file.transaction do
@@ -39,7 +45,7 @@ module Packages
target_package = existing_package
else
if symbol_package?
- raise InvalidMetadataError, 'symbol package is invalid, matching package does not exist'
+ raise InvalidMetadataError, MISSING_MATCHING_PACKAGE_ERROR_MESSAGE
end
update_linked_package
@@ -55,17 +61,21 @@ module Packages
return if symbol_package?
::Packages::Nuget::SyncMetadatumService
- .new(package, metadata.slice(:project_url, :license_url, :icon_url))
+ .new(package, metadata.slice(:authors, :description, :project_url, :license_url, :icon_url))
.execute
+
::Packages::UpdateTagsService
.new(package, package_tags)
.execute
+
rescue StandardError => e
raise InvalidMetadataError, e.message
end
def valid_metadata?
- package_name.present? && package_version.present?
+ fields = [package_name, package_version, package_description]
+ fields << package_authors unless symbol_package?
+ fields.all?(&:present?)
end
def link_to_existing_package
@@ -93,15 +103,14 @@ module Packages
end
def existing_package
- strong_memoize(:existing_package) do
- @package_file.project.packages
- .nuget
- .with_name(package_name)
- .with_version(package_version)
- .not_pending_destruction
- .first
- end
+ @package_file.project.packages
+ .nuget
+ .with_name(package_name)
+ .with_version(package_version)
+ .not_pending_destruction
+ .first
end
+ strong_memoize_attr :existing_package
def package_name
metadata[:package_name]
@@ -123,15 +132,22 @@ module Packages
metadata.fetch(:package_types, [])
end
+ def package_authors
+ metadata[:authors]
+ end
+
+ def package_description
+ metadata[:description]
+ 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
- end
+ ::Packages::Nuget::MetadataExtractionService.new(@package_file.id).execute
end
+ strong_memoize_attr :metadata
def package_filename
"#{package_name.downcase}.#{package_version.downcase}.#{symbol_package? ? 'snupkg' : 'nupkg'}"
diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb
index b464ce4504a..087a8e42a66 100644
--- a/app/services/packages/pypi/create_package_service.rb
+++ b/app/services/packages/pypi/create_package_service.rb
@@ -29,10 +29,9 @@ module Packages
private
def created_package
- strong_memoize(:created_package) do
- find_or_create_package!(:pypi)
- end
+ find_or_create_package!(:pypi)
end
+ strong_memoize_attr :created_package
def file_params
{
diff --git a/app/services/packages/rpm/parse_package_service.rb b/app/services/packages/rpm/parse_package_service.rb
index d2751c77c5b..3995eedef53 100644
--- a/app/services/packages/rpm/parse_package_service.rb
+++ b/app/services/packages/rpm/parse_package_service.rb
@@ -43,10 +43,9 @@ module Packages
end
def package_tags
- strong_memoize(:package_tags) do
- rpm.tags
- end
+ rpm.tags
end
+ strong_memoize_attr :package_tags
def extract_static_attributes
STATIC_ATTRIBUTES.index_with do |attribute|
diff --git a/app/services/packages/rubygems/dependency_resolver_service.rb b/app/services/packages/rubygems/dependency_resolver_service.rb
index 839a7683632..214a4adc47f 100644
--- a/app/services/packages/rubygems/dependency_resolver_service.rb
+++ b/app/services/packages/rubygems/dependency_resolver_service.rb
@@ -33,10 +33,9 @@ module Packages
private
def packages
- strong_memoize(:packages) do
- project.packages.with_name(gem_name)
- end
+ project.packages.with_name(gem_name)
end
+ strong_memoize_attr :packages
def gem_name
params[:gem_name]
diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb
index c771af28f73..ca4aaa8fdde 100644
--- a/app/services/packages/rubygems/process_gem_service.rb
+++ b/app/services/packages/rubygems/process_gem_service.rb
@@ -64,10 +64,9 @@ module Packages
end
def gemspec
- strong_memoize(:gemspec) do
- gem.spec
- end
+ gem.spec
end
+ strong_memoize_attr :gemspec
def success
ServiceResponse.success(payload: { package: package })
@@ -78,24 +77,21 @@ module Packages
end
def temp_package
- strong_memoize(:temp_package) do
- package_file.package
- end
+ package_file.package
end
+ strong_memoize_attr :temp_package
def package
- strong_memoize(:package) do
- # if package with name/version already exists, use that package
- package = temp_package.project
+ package = temp_package.project
.packages
.rubygems
.with_name(gemspec.name)
.with_version(gemspec.version.to_s)
.not_pending_destruction
.last
- package || temp_package
- end
+ package || temp_package
end
+ strong_memoize_attr :package
def gem
# use_file will set an exclusive lease on the file for as long as
diff --git a/app/services/packages/terraform_module/create_package_service.rb b/app/services/packages/terraform_module/create_package_service.rb
index 3afecc6c1ca..9df722db529 100644
--- a/app/services/packages/terraform_module/create_package_service.rb
+++ b/app/services/packages/terraform_module/create_package_service.rb
@@ -43,16 +43,14 @@ module Packages
end
def name
- strong_memoize(:name) do
- "#{params[:module_name]}/#{params[:module_system]}"
- end
+ "#{params[:module_name]}/#{params[:module_system]}"
end
+ strong_memoize_attr :name
def file_name
- strong_memoize(:file_name) do
- "#{params[:module_name]}-#{params[:module_system]}-#{params[:module_version]}.tgz"
- end
+ "#{params[:module_name]}-#{params[:module_system]}-#{params[:module_version]}.tgz"
end
+ strong_memoize_attr :file_name
def file_params
{
diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb
index f29c54dacb9..cf1acc6ee19 100644
--- a/app/services/packages/update_tags_service.rb
+++ b/app/services/packages/update_tags_service.rb
@@ -21,10 +21,9 @@ module Packages
private
def existing_tags
- strong_memoize(:existing_tags) do
- @package.tag_names
- end
+ @package.tag_names
end
+ strong_memoize_attr :existing_tags
def rows(tags)
now = Time.zone.now
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
index adb7924f35e..31ba88af46c 100644
--- a/app/services/personal_access_tokens/create_service.rb
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -13,7 +13,7 @@ module PersonalAccessTokens
def execute
return ServiceResponse.error(message: 'Not permitted to create') unless creation_permitted?
- token = target_user.personal_access_tokens.create(params.slice(*allowed_params))
+ token = target_user.personal_access_tokens.create(personal_access_token_params)
if token.persisted?
log_event(token)
@@ -31,13 +31,17 @@ module PersonalAccessTokens
attr_reader :target_user, :ip_address
- def allowed_params
- [
- :name,
- :impersonation,
- :scopes,
- :expires_at
- ]
+ def personal_access_token_params
+ {
+ name: params[:name],
+ impersonation: params[:impersonation] || false,
+ scopes: params[:scopes],
+ expires_at: pat_expiration
+ }
+ end
+
+ def pat_expiration
+ params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
end
def creation_permitted?
diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb
index 9066fd1acdf..6fc3110a70b 100644
--- a/app/services/personal_access_tokens/last_used_service.rb
+++ b/app/services/personal_access_tokens/last_used_service.rb
@@ -22,7 +22,14 @@ module PersonalAccessTokens
last_used = @personal_access_token.last_used_at
- last_used.nil? || (last_used <= 1.day.ago)
+ return true if last_used.nil?
+
+ if Feature.enabled?(:update_personal_access_token_usage_information_every_10_minutes) &&
+ last_used <= 10.minutes.ago
+ return true
+ end
+
+ last_used <= 1.day.ago
end
end
end
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index c376b4036f8..5ab5732ecf5 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -86,14 +86,15 @@ class PostReceiveService
banner = nil
if project
- scoped_messages = BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message|
- message.target_path.present? && message.matches_current_path(project.full_path)
- end
+ scoped_messages =
+ BroadcastMessage.current_banner_messages(current_path: project.full_path).select do |message|
+ message.target_path.present? && message.matches_current_path(project.full_path) && message.show_in_cli?
+ end
banner = scoped_messages.last
end
- banner ||= BroadcastMessage.current_banner_messages.last
+ banner ||= BroadcastMessage.current_show_in_cli_banner_messages.last
banner&.message
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 8ad2b0ac761..e37b6516d21 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -24,7 +24,7 @@ module Projects
def execute
params[:wiki_enabled] = params[:wiki_access_level] if params[:wiki_access_level]
params[:builds_enabled] = params[:builds_access_level] if params[:builds_access_level]
- params[:snippets_enabled] = params[:builds_access_level] if params[:snippets_access_level]
+ params[:snippets_enabled] = params[:snippets_access_level] if params[:snippets_access_level]
params[:merge_requests_enabled] = params[:merge_requests_access_level] if params[:merge_requests_access_level]
params[:issues_enabled] = params[:issues_access_level] if params[:issues_access_level]
@@ -231,7 +231,7 @@ module Projects
@project.create_labels unless @project.gitlab_project_import?
- break if @project.import?
+ next if @project.import?
unless @project.create_repository(default_branch: default_branch)
raise 'Failed to create repository'
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index ceab7098b32..e22b728cea3 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -20,6 +20,8 @@ module Projects
add_repository_to_project
+ validate_repository_size!
+
download_lfs_objects
import_data
@@ -58,6 +60,10 @@ module Projects
attr_reader :resolved_address
+ def validate_repository_size!
+ # Defined in EE::Projects::ImportService
+ end
+
def after_execute_hook
# Defined in EE::Projects::ImportService
end
diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb
index c9791041088..95ddff45dff 100644
--- a/app/services/projects/lfs_pointers/lfs_import_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_import_service.rb
@@ -14,7 +14,7 @@ module Projects
end
success
- rescue StandardError => e
+ rescue StandardError, GRPC::Core::CallError => e
error(e.message)
end
end
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index d0bef9da329..e7a8d5305ea 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -14,8 +14,6 @@ module Projects
def project_update_params
error_tracking_params
.merge(alerting_setting_params)
- .merge(metrics_setting_params)
- .merge(grafana_integration_params)
.merge(prometheus_integration_params)
.merge(incident_management_setting_params)
end
@@ -37,15 +35,6 @@ module Projects
{ alerting_setting_attributes: attr }
end
- def metrics_setting_params
- attribs = params[:metrics_setting_attributes]
- return {} unless attribs
-
- attribs[:external_dashboard_url] = attribs[:external_dashboard_url].presence
-
- { metrics_setting_attributes: attribs }
- end
-
def error_tracking_params
settings = params[:error_tracking_setting_attributes]
return {} if settings.blank?
@@ -99,14 +88,6 @@ module Projects
params
end
- def grafana_integration_params
- return {} unless attrs = params[:grafana_integration_attributes]
-
- destroy = attrs[:grafana_url].blank? && attrs[:token].blank?
-
- { grafana_integration_attributes: attrs.merge(_destroy: destroy) }
- end
-
def prometheus_integration_params
return {} unless attrs = params[:prometheus_integration_attributes]
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index c29770d0c5f..8c807e0016b 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -45,7 +45,7 @@ module Projects
def visible_groups
visible_groups = project.invited_groups
- unless project.team.owner?(current_user)
+ unless project.team.member?(current_user)
visible_groups = visible_groups.public_or_visible_to_user(current_user)
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 1e084c0e5eb..f1c093c89b7 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -36,7 +36,7 @@ module Projects
truncate_alerts! if max_alerts_exceeded?
- process_prometheus_alerts
+ process_prometheus_alerts(integration)
created
end
@@ -79,12 +79,18 @@ module Projects
end
def valid_alert_manager_token?(token, integration)
- valid_for_manual?(token) ||
- valid_for_alerts_endpoint?(token, integration) ||
+ valid_for_alerts_endpoint?(token, integration) ||
+ valid_for_manual?(token) ||
valid_for_cluster?(token)
end
def valid_for_manual?(token)
+ # If migration from Integrations::Prometheus to
+ # AlertManagement::HttpIntegrations is complete,
+ # we should use use the HttpIntegration as SSOT.
+ # Remove with https://gitlab.com/gitlab-org/gitlab/-/issues/409734
+ return false if project.alert_management_http_integrations.legacy.prometheus.any?
+
prometheus = project.find_or_initialize_integration('prometheus')
return false unless prometheus.manual_configuration?
@@ -145,10 +151,10 @@ module Projects
ActiveSupport::SecurityUtils.secure_compare(expected, actual)
end
- def process_prometheus_alerts
+ def process_prometheus_alerts(integration)
alerts.map do |alert|
AlertManagement::ProcessPrometheusAlertService
- .new(project, alert)
+ .new(project, alert, integration: integration)
.execute
end
end
diff --git a/app/services/projects/readme_renderer_service.rb b/app/services/projects/readme_renderer_service.rb
index 6871976aded..8fd33a717c5 100644
--- a/app/services/projects/readme_renderer_service.rb
+++ b/app/services/projects/readme_renderer_service.rb
@@ -17,9 +17,9 @@ module Projects
end
def sanitized_filename(template_name)
- path = Gitlab::Utils.check_path_traversal!("#{template_name}.md.tt")
+ path = Gitlab::PathTraversal.check_path_traversal!("#{template_name}.md.tt")
path = TEMPLATE_PATH.join(path).to_s
- Gitlab::Utils.check_allowed_absolute_path!(path, [TEMPLATE_PATH.to_s])
+ Gitlab::PathTraversal.check_allowed_absolute_path!(path, [TEMPLATE_PATH.to_s])
path
end
diff --git a/app/services/projects/slack_application_install_service.rb b/app/services/projects/slack_application_install_service.rb
new file mode 100644
index 00000000000..812b8b0a082
--- /dev/null
+++ b/app/services/projects/slack_application_install_service.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Projects
+ class SlackApplicationInstallService < BaseService
+ include Gitlab::Routing
+
+ # Endpoint to initiate the OAuth flow, redirects to Slack's authorization screen
+ # https://api.slack.com/authentication/oauth-v2#asking
+ SLACK_AUTHORIZE_URL = 'https://slack.com/oauth/v2/authorize'
+
+ # Endpoint to exchange the temporary authorization code for an access token
+ # https://api.slack.com/authentication/oauth-v2#exchanging
+ SLACK_EXCHANGE_TOKEN_URL = 'https://slack.com/api/oauth.v2.access'
+
+ def execute
+ slack_data = exchange_slack_token
+
+ return error("Slack: #{slack_data['error']}") unless slack_data['ok']
+
+ integration = project.gitlab_slack_application_integration \
+ || project.create_gitlab_slack_application_integration!
+
+ installation = integration.slack_integration || integration.build_slack_integration
+
+ installation.update!(
+ bot_user_id: slack_data['bot_user_id'],
+ bot_access_token: slack_data['access_token'],
+ team_id: slack_data.dig('team', 'id'),
+ team_name: slack_data.dig('team', 'name'),
+ alias: project.full_path,
+ user_id: slack_data.dig('authed_user', 'id'),
+ authorized_scope_names: slack_data['scope']
+ )
+
+ update_legacy_installations!(installation)
+
+ success
+ end
+
+ private
+
+ def exchange_slack_token
+ query = {
+ client_id: Gitlab::CurrentSettings.slack_app_id,
+ client_secret: Gitlab::CurrentSettings.slack_app_secret,
+ code: params[:code],
+ # NOTE: Needs to match the `redirect_uri` passed to the authorization endpoint,
+ # otherwise we get a `bad_redirect_uri` error.
+ redirect_uri: slack_auth_project_settings_slack_url(project)
+ }
+
+ Gitlab::HTTP.get(SLACK_EXCHANGE_TOKEN_URL, query: query).to_hash
+ end
+
+ # Update any legacy SlackIntegration records for the Slack Workspace. Legacy SlackIntegration records
+ # are any created before our Slack App was upgraded to use Granular Bot Permissions and issue a
+ # bot_access_token. Any SlackIntegration records for the Slack Workspace will already have the same
+ # bot_access_token.
+ def update_legacy_installations!(installation)
+ updatable_attributes = installation.attributes.slice(
+ 'user_id',
+ 'bot_user_id',
+ 'encrypted_bot_access_token',
+ 'encrypted_bot_access_token_iv',
+ 'updated_at'
+ )
+
+ SlackIntegration.by_team(installation.team_id).id_not_in(installation.id).each_batch do |batch|
+ batch_ids = batch.pluck(:id) # rubocop: disable CodeReuse/ActiveRecord
+ batch.update_all(updatable_attributes)
+
+ ::Integrations::SlackWorkspace::IntegrationApiScope.update_scopes(batch_ids, installation.slack_api_scopes)
+ end
+ end
+ end
+end
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index e5883ca06f4..f0243d844d9 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -81,11 +81,17 @@ module Releases
tag: tag.name,
sha: tag.dereferenced_target.sha,
released_at: released_at,
- links_attributes: params.dig(:assets, 'links') || [],
+ links_attributes: links_attributes,
milestones: milestones
)
end
+ def links_attributes
+ (params.dig(:assets, 'links') || []).map do |link_params|
+ Releases::Links::Params.new(link_params).allowed_params
+ end
+ end
+
def create_evidence!(release, pipeline)
return if release.historical_release? || release.upcoming_release?
diff --git a/app/services/releases/links/base_service.rb b/app/services/releases/links/base_service.rb
index 8bab258f80a..4c260e3183f 100644
--- a/app/services/releases/links/base_service.rb
+++ b/app/services/releases/links/base_service.rb
@@ -18,17 +18,7 @@ module Releases
private
def allowed_params
- @allowed_params ||= params.slice(:name, :url, :link_type).tap do |hash|
- hash[:filepath] = filepath if provided_filepath?
- end
- end
-
- def provided_filepath?
- params.key?(:direct_asset_path) || params.key?(:filepath)
- end
-
- def filepath
- params[:direct_asset_path] || params[:filepath]
+ Params.new(params).allowed_params
end
end
end
diff --git a/app/services/releases/links/params.rb b/app/services/releases/links/params.rb
new file mode 100644
index 00000000000..124ab333bbc
--- /dev/null
+++ b/app/services/releases/links/params.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Releases
+ module Links
+ class Params
+ def initialize(params)
+ @params = params.with_indifferent_access
+ end
+
+ def allowed_params
+ @allowed_params ||= params.slice(:name, :url, :link_type).tap do |hash|
+ hash[:filepath] = filepath if provided_filepath?
+ end
+ end
+
+ private
+
+ attr_reader :params
+
+ def provided_filepath?
+ params.key?(:direct_asset_path) || params.key?(:filepath)
+ end
+
+ def filepath
+ params[:direct_asset_path] || params[:filepath]
+ end
+ end
+ end
+end
diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb
index 4d7e4ffe267..b262b4a1f7b 100644
--- a/app/services/repositories/base_service.rb
+++ b/app/services/repositories/base_service.rb
@@ -29,7 +29,7 @@ class Repositories::BaseService < BaseService
end
def move_error(path)
- error = %Q{Repository "#{path}" could not be moved}
+ error = %{Repository "#{path}" could not be moved}
log_error(error)
error(error)
diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb
index 553315f08f9..1fea894a599 100644
--- a/app/services/resource_access_tokens/create_service.rb
+++ b/app/services/resource_access_tokens/create_service.rb
@@ -17,6 +17,8 @@ module ResourceAccessTokens
access_level = params[:access_level] || Gitlab::Access::MAINTAINER
return error("Could not provision owner access to project access token") if do_not_allow_owner_access_level_for_project_bot?(access_level)
+ return error(s_('AccessTokens|Access token limit reached')) if reached_access_token_limit?
+
user = create_user
return error(user.errors.full_messages.to_sentence) unless user.persisted?
@@ -45,6 +47,10 @@ module ResourceAccessTokens
attr_reader :resource_type, :resource
+ def reached_access_token_limit?
+ false
+ end
+
def username_and_email_generator
Gitlab::Utils::UsernameAndEmailGenerator.new(
username_prefix: "#{resource_type}_#{resource.id}_bot",
@@ -91,7 +97,7 @@ module ResourceAccessTokens
name: params[:name] || "#{resource_type}_bot",
impersonation: false,
scopes: params[:scopes] || default_scopes,
- expires_at: params[:expires_at] || nil
+ expires_at: pat_expiration
}
end
@@ -100,15 +106,11 @@ module ResourceAccessTokens
end
def create_membership(resource, user, access_level)
- resource.add_member(user, access_level, expires_at: default_pat_expiration)
+ resource.add_member(user, access_level, expires_at: pat_expiration)
end
- def default_pat_expiration
- if Feature.enabled?(:default_pat_expiration)
- params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
- else
- params[:expires_at]
- end
+ def pat_expiration
+ params[:expires_at].presence || PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now
end
def log_event(token)
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index cee59360b4b..f4c0a743ef0 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -2,9 +2,11 @@
module Search
class GlobalService
+ include Search::Filter
include Gitlab::Utils::StrongMemoize
- ALLOWED_SCOPES = %w(issues merge_requests milestones users).freeze
+ DEFAULT_SCOPE = 'projects'
+ ALLOWED_SCOPES = %w(projects issues merge_requests milestones users).freeze
attr_accessor :current_user, :params
@@ -19,12 +21,12 @@ module Search
projects,
order_by: params[:order_by],
sort: params[:sort],
- filters: { state: params[:state], confidential: params[:confidential] })
+ filters: filters)
end
# rubocop: disable CodeReuse/ActiveRecord
def projects
- @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :project_topics)
+ @projects ||= ProjectsFinder.new(current_user: current_user).execute.preload(:topics, :project_topics)
end
def allowed_scopes
@@ -33,7 +35,7 @@ module Search
def scope
strong_memoize(:scope) do
- allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects'
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE
end
end
end
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index daed0df83f3..fa80a6ecf58 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -18,7 +18,7 @@ module Search
group: group,
order_by: params[:order_by],
sort: params[:sort],
- filters: { state: params[:state], confidential: params[:confidential] }
+ filters: filters
)
end
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index 6acc32ea0a8..71314f85984 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -2,9 +2,11 @@
module Search
class ProjectService
+ include Search::Filter
include Gitlab::Utils::StrongMemoize
+ include ProjectsHelper
- ALLOWED_SCOPES = %w(notes issues merge_requests milestones wiki_blobs commits users).freeze
+ ALLOWED_SCOPES = %w(blobs issues merge_requests wiki_blobs commits notes milestones users).freeze
attr_accessor :project, :current_user, :params
@@ -21,7 +23,7 @@ module Search
repository_ref: params[:repository_ref],
order_by: params[:order_by],
sort: params[:sort],
- filters: { confidential: params[:confidential], state: params[:state] }
+ filters: filters
)
end
@@ -31,7 +33,11 @@ module Search
def scope
strong_memoize(:scope) do
- allowed_scopes.include?(params[:scope]) ? params[:scope] : 'blobs'
+ next params[:scope] if allowed_scopes.include?(params[:scope]) && project_search_tabs?(params[:scope].to_sym)
+
+ allowed_scopes.find do |scope|
+ project_search_tabs?(scope.to_sym)
+ end
end
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 7fca6ed7a20..5705e4c7cef 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -113,6 +113,8 @@ class SearchService
end
def global_search_enabled_for_scope?
+ return false if show_snippets? && Feature.disabled?(:global_search_snippet_titles_tab, current_user, type: :ops)
+
case params[:scope]
when 'blobs'
Feature.enabled?(:global_search_code_tab, current_user, type: :ops)
@@ -122,6 +124,8 @@ class SearchService
Feature.enabled?(:global_search_issues_tab, current_user, type: :ops)
when 'merge_requests'
Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops)
+ when 'snippet_titles'
+ Feature.enabled?(:global_search_snippet_titles_tab, current_user, type: :ops)
when 'wiki_blobs'
Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops)
when 'users'
diff --git a/app/services/service_desk/custom_email_verifications/base_service.rb b/app/services/service_desk/custom_email_verifications/base_service.rb
new file mode 100644
index 00000000000..fe456e4d3f3
--- /dev/null
+++ b/app/services/service_desk/custom_email_verifications/base_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmailVerifications
+ class BaseService < ::BaseProjectService
+ attr_reader :settings
+
+ def initialize(project:, current_user: nil, params: {})
+ super(project: project, current_user: current_user, params: params)
+
+ @settings = project.service_desk_setting
+ end
+
+ private
+
+ def notify_project_owners_and_user_with_email(email_method_name: nil, user: nil)
+ owner_emails = project.owners.map(&:email)
+
+ owner_emails << user.email if user.present?
+
+ owner_emails.uniq(&:downcase).each do |email_address|
+ Notify.try(email_method_name, settings, email_address).deliver_later
+ end
+ end
+
+ def notify_project_owners_and_user_about_result(user: nil)
+ notify_project_owners_and_user_with_email(
+ email_method_name: :service_desk_verification_result_email,
+ user: user
+ )
+ end
+
+ def error_feature_flag_disabled
+ error_response('Feature flag service_desk_custom_email is not enabled')
+ end
+
+ def error_response(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_not_verified(error_identifier)
+ ServiceResponse.error(
+ message: _('ServiceDesk|Custom email address could not be verified.'),
+ reason: error_identifier.to_s
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/service_desk/custom_email_verifications/create_service.rb b/app/services/service_desk/custom_email_verifications/create_service.rb
new file mode 100644
index 00000000000..db518bfdf24
--- /dev/null
+++ b/app/services/service_desk/custom_email_verifications/create_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmailVerifications
+ class CreateService < BaseService
+ attr_reader :ramp_up_error
+
+ def execute
+ return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project)
+ return error_settings_missing unless settings.present?
+ return error_user_not_authorized unless can?(current_user, :admin_project, project)
+
+ update_settings
+ notify_project_owners_and_user_about_verification_start
+ send_verification_email_and_catch_delivery_errors
+
+ if ramp_up_error
+ handle_error_case
+ else
+ ServiceResponse.success
+ end
+ end
+
+ private
+
+ def verification
+ @verification ||= settings.custom_email_verification ||
+ ServiceDesk::CustomEmailVerification.new(project_id: settings.project_id)
+ end
+
+ def update_settings
+ settings.update!(custom_email_enabled: false) if settings.custom_email_enabled?
+
+ verification.mark_as_started!(current_user)
+ # We use verification association from project, to use it in email, we need to reset it here.
+ project.reset
+ end
+
+ def notify_project_owners_and_user_about_verification_start
+ notify_project_owners_and_user_with_email(
+ email_method_name: :service_desk_verification_triggered_email,
+ user: current_user
+ )
+ end
+
+ def send_verification_email_and_catch_delivery_errors
+ # Send this synchronously as we need to get direct feedback on delivery errors.
+ Notify.service_desk_custom_email_verification_email(settings).deliver
+ rescue SocketError, OpenSSL::SSL::SSLError
+ # e.g. host not found or host certificate issues
+ @ramp_up_error = :smtp_host_issue
+ rescue Net::SMTPAuthenticationError
+ # incorrect username or password
+ @ramp_up_error = :invalid_credentials
+ end
+
+ def handle_error_case
+ notify_project_owners_and_user_about_result(user: current_user)
+
+ verification.mark_as_failed!(ramp_up_error)
+
+ error_not_verified(ramp_up_error)
+ end
+
+ def error_settings_missing
+ error_response(_('ServiceDesk|Service Desk setting missing'))
+ end
+
+ def error_user_not_authorized
+ error_response(_('ServiceDesk|User cannot manage project.'))
+ end
+ end
+ end
+end
diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb
new file mode 100644
index 00000000000..813624cde23
--- /dev/null
+++ b/app/services/service_desk/custom_email_verifications/update_service.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module ServiceDesk
+ module CustomEmailVerifications
+ class UpdateService < BaseService
+ EMAIL_TOKEN_REGEXP = /Verification token: ([A-Za-z0-9_-]{12})/
+
+ def execute
+ return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project)
+ return error_parameter_missing if settings.blank? || verification.blank?
+ return error_already_finished if already_finished_and_no_mail?
+ return error_already_failed if already_failed_and_no_mail?
+
+ verification_error = verify
+
+ settings.update!(custom_email_enabled: false) if settings.custom_email_enabled?
+
+ notify_project_owners_and_user_about_result(user: verification.triggerer)
+
+ if verification_error.present?
+ verification.mark_as_failed!(verification_error)
+
+ error_not_verified(verification_error)
+ else
+ verification.mark_as_finished!
+
+ ServiceResponse.success
+ end
+ end
+
+ private
+
+ def mail
+ params[:mail]
+ end
+
+ def verification
+ @verification ||= settings.custom_email_verification
+ end
+
+ def already_finished_and_no_mail?
+ verification.finished? && mail.blank?
+ end
+
+ def already_failed_and_no_mail?
+ verification.failed? && mail.blank?
+ end
+
+ def verify
+ return :mail_not_received_within_timeframe if mail_not_received_within_timeframe?
+ return :incorrect_from if incorrect_from?
+ return :incorrect_token if incorrect_token?
+
+ nil
+ end
+
+ def mail_not_received_within_timeframe?
+ # (For completeness) also raise if no email provided
+ mail.blank? || !verification.in_timeframe?
+ end
+
+ def incorrect_from?
+ # Does the email forwarder preserve the FROM header?
+ mail.from.first != settings.custom_email
+ end
+
+ def incorrect_token?
+ message, _stripped_text = Gitlab::Email::ReplyParser.new(mail).execute
+
+ scan_result = message.scan(EMAIL_TOKEN_REGEXP)
+
+ return true if scan_result.empty?
+
+ scan_result.first.first != verification.token
+ end
+
+ def error_parameter_missing
+ error_response(_('ServiceDesk|Service Desk setting or verification object missing'))
+ end
+
+ def error_already_finished
+ error_response(_('ServiceDesk|Custom email address has already been verified.'))
+ end
+
+ def error_already_failed
+ error_response(_('ServiceDesk|Custom email address verification has already been processed and failed.'))
+ end
+ end
+ end
+end
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 6d39174b6c7..72d0c022609 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -3,7 +3,7 @@
module ServicePing
class SubmitService
PRODUCTION_BASE_URL = 'https://version.gitlab.com'
- STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org'
+ STAGING_BASE_URL = 'https://gitlab-org-gitlab-services-version-gitlab-com-staging.version-staging.gitlab.org'
USAGE_DATA_PATH = 'usage_data'
ERROR_PATH = 'usage_ping_errors'
METADATA_PATH = 'usage_ping_metadata'
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index a62d5290271..569b8b76518 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -2,11 +2,9 @@
module Snippets
class CreateService < Snippets::BaseService
- # 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:, spam_params:, current_user: nil, params: {})
+ def initialize(project:, current_user: nil, params: {}, perform_spam_check: true)
super(project: project, current_user: current_user, params: params)
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
end
def execute
@@ -20,16 +18,12 @@ module Snippets
@snippet.author = current_user
- Spam::SpamActionService.new(
- spammable: @snippet,
- spam_params: spam_params,
- user: current_user,
- action: :create,
- extra_features: { files: file_paths_to_commit }
- ).execute
+ if perform_spam_check
+ @snippet.check_for_spam(user: current_user, action: :create, extra_features: { files: file_paths_to_commit })
+ end
if save_and_commit
- UserAgentDetailService.new(spammable: @snippet, spam_params: spam_params).create
+ UserAgentDetailService.new(spammable: @snippet, perform_spam_check: perform_spam_check).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create)
move_temporary_files
@@ -42,7 +36,7 @@ module Snippets
private
- attr_reader :snippet, :spam_params
+ attr_reader :snippet, :perform_spam_check
def build_from_params
if project
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 067680f2abc..662e31a93aa 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -6,12 +6,9 @@ module Snippets
UpdateError = Class.new(StandardError)
- # 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)
+ def initialize(project:, current_user: nil, params: {}, perform_spam_check: false)
super(project: project, current_user: current_user, params: params)
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
end
def execute(snippet)
@@ -25,13 +22,9 @@ module Snippets
files = snippet.all_files.map { |f| { path: f } } + file_paths_to_commit
- Spam::SpamActionService.new(
- spammable: snippet,
- spam_params: spam_params,
- user: current_user,
- action: :update,
- extra_features: { files: files }
- ).execute
+ if perform_spam_check
+ snippet.check_for_spam(user: current_user, action: :update, extra_features: { files: files })
+ end
if save_and_commit(snippet)
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
@@ -44,7 +37,7 @@ module Snippets
private
- attr_reader :spam_params
+ attr_reader :perform_spam_check
def visibility_changed?(snippet)
visibility_level && visibility_level.to_i != snippet.visibility_level
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 7c96f003e46..0527412e9bc 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -4,22 +4,35 @@ module Spam
class SpamActionService
include SpamConstants
- def initialize(spammable:, spam_params:, user:, action:, extra_features: {})
+ def initialize(spammable:, user:, action:, extra_features: {})
@target = spammable
- @spam_params = spam_params
@user = user
@action = action
@extra_features = extra_features
end
- # rubocop:disable Metrics/AbcSize
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
+ return ServiceResponse.success(message: 'Skipped spam check because user was not present') unless user
+ if target.supports_recaptcha?
+ execute_with_captcha_support
+ else
+ execute_spam_check
+ end
+ end
+
+ delegate :check_for_spam?, to: :target
+
+ private
+
+ attr_reader :user, :action, :target, :spam_log, :extra_features
+
+ def spam_params
+ Gitlab::RequestContext.instance.spam_params
+ end
+
+ def execute_with_captcha_support
recaptcha_verified = Captcha::CaptchaVerificationService.new(spam_params: spam_params).execute
if recaptcha_verified
@@ -28,20 +41,17 @@ module Spam
SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id)
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 it was not required') unless check_for_spam?(user: user)
-
- perform_spam_service_check
- ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
+ execute_spam_check
end
end
- # rubocop:enable Metrics/AbcSize
- delegate :check_for_spam?, to: :target
-
- private
+ def execute_spam_check
+ return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user)
+ return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam?(user: user)
- attr_reader :user, :action, :target, :spam_params, :spam_log, :extra_features
+ perform_spam_service_check
+ ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement")
+ end
##
# In order to be proceed to the spam check process, the target must be
diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb
index 1279adf327b..2ecd431fd91 100644
--- a/app/services/spam/spam_verdict_service.rb
+++ b/app/services/spam/spam_verdict_service.rb
@@ -68,7 +68,7 @@ module Spam
begin
result = spamcheck_client.spam?(spammable: target, user: user, context: context, extra_features: extra_features)
- if result.evaluated? && Feature.enabled?(:user_spam_scores)
+ if result.evaluated?
Abuse::TrustScore.create!(user: user, score: result.score, source: :spamcheck)
end
diff --git a/app/services/tasks_to_be_done/base_service.rb b/app/services/tasks_to_be_done/base_service.rb
index 1c74e803e0b..1d50e5081ff 100644
--- a/app/services/tasks_to_be_done/base_service.rb
+++ b/app/services/tasks_to_be_done/base_service.rb
@@ -19,7 +19,7 @@ module TasksToBeDone
update_service = Issues::UpdateService.new(container: project, current_user: current_user, params: { add_assignee_ids: params[:assignee_ids] })
update_service.execute(issue)
else
- create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, spam_params: nil)
+ create_service = Issues::CreateService.new(container: project, current_user: current_user, params: params, perform_spam_check: false)
create_service.execute
end
end
diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb
index 01a98a15869..ccb5cec2df8 100644
--- a/app/services/user_agent_detail_service.rb
+++ b/app/services/user_agent_detail_service.rb
@@ -1,15 +1,16 @@
# frozen_string_literal: true
class UserAgentDetailService
- def initialize(spammable:, spam_params:)
+ def initialize(spammable:, perform_spam_check:)
@spammable = spammable
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
end
def create
- 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)
+ spam_params = Gitlab::RequestContext.instance.spam_params
+ if !perform_spam_check || spam_params&.user_agent.blank? || spam_params&.ip_address.blank?
+ message = 'Skipped UserAgentDetail creation because necessary spam_params were not provided'
+ return ServiceResponse.success(message: message)
end
spammable.create_user_agent_detail(user_agent: spam_params.user_agent, ip_address: spam_params.ip_address)
@@ -17,5 +18,5 @@ class UserAgentDetailService
private
- attr_reader :spammable, :spam_params
+ attr_reader :spammable, :perform_spam_check
end
diff --git a/app/services/users/activate_service.rb b/app/services/users/activate_service.rb
new file mode 100644
index 00000000000..dfc2996bcce
--- /dev/null
+++ b/app/services/users/activate_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Users
+ class ActivateService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ return error(_('You are not authorized to perform this action'), :forbidden) unless allowed?
+
+ return error(_('Error occurred. A blocked user must be unblocked to be activated'), :forbidden) if user.blocked?
+
+ return success(_('Successfully activated')) if user.active?
+
+ if user.activate
+ after_activate_hook(user)
+ log_event(user)
+ success(_('Successfully activated'))
+ else
+ error(user.errors.full_messages.to_sentence, :unprocessable_entity)
+ end
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def allowed?
+ can?(current_user, :admin_all_resources)
+ end
+
+ def after_activate_hook(user)
+ # overridden by EE module
+ end
+
+ def log_event(user)
+ Gitlab::AppLogger.info(message: 'User activated', user: user.username.to_s, email: user.email.to_s,
+ activated_by: current_user.username.to_s, ip_address: current_user.current_sign_in_ip.to_s)
+ end
+
+ def success(message)
+ ::ServiceResponse.success(message: message)
+ end
+
+ def error(message, reason)
+ ::ServiceResponse.error(message: message, reason: reason)
+ end
+ end
+end
+
+Users::ActivateService.prepend_mod_with('Users::ActivateService') # rubocop: disable Cop/InjectEnterpriseEditionModule
diff --git a/app/services/users/set_namespace_commit_email_service.rb b/app/services/users/set_namespace_commit_email_service.rb
new file mode 100644
index 00000000000..30ee597120d
--- /dev/null
+++ b/app/services/users/set_namespace_commit_email_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Users
+ class SetNamespaceCommitEmailService
+ include Gitlab::Allowable
+
+ attr_reader :current_user, :target_user, :namespace, :email_id
+
+ def initialize(current_user, namespace, email_id, params)
+ @current_user = current_user
+ @target_user = params.delete(:user) || current_user
+ @namespace = namespace
+ @email_id = email_id
+ end
+
+ def execute
+ return error(_('Namespace must be provided.')) if namespace.nil?
+
+ unless can?(current_user, :admin_user_email_address, target_user)
+ return error(_("User doesn't exist or you don't have permission to change namespace commit emails."))
+ end
+
+ unless can?(target_user, :read_namespace, namespace)
+ return error(_("Namespace doesn't exist or you don't have permission."))
+ end
+
+ email = target_user.emails.find_by(id: email_id) unless email_id.nil? # rubocop: disable CodeReuse/ActiveRecord
+ existing_namespace_commit_email = target_user.namespace_commit_email_for_namespace(namespace)
+ if existing_namespace_commit_email.nil?
+ return error(_('Email must be provided.')) if email.nil?
+
+ create_namespace_commit_email(email)
+ elsif email_id.nil?
+ remove_namespace_commit_email(existing_namespace_commit_email)
+ else
+ update_namespace_commit_email(existing_namespace_commit_email, email)
+ end
+ end
+
+ private
+
+ def remove_namespace_commit_email(namespace_commit_email)
+ namespace_commit_email.destroy
+ success(nil)
+ end
+
+ def create_namespace_commit_email(email)
+ namespace_commit_email = ::Users::NamespaceCommitEmail.new(
+ user: target_user,
+ namespace: namespace,
+ email: email
+ )
+
+ save_namespace_commit_email(namespace_commit_email)
+ end
+
+ def update_namespace_commit_email(namespace_commit_email, email)
+ namespace_commit_email.email = email
+
+ save_namespace_commit_email(namespace_commit_email)
+ end
+
+ def save_namespace_commit_email(namespace_commit_email)
+ if !namespace_commit_email.save
+ error_in_save(namespace_commit_email)
+ else
+ success(namespace_commit_email)
+ end
+ end
+
+ def success(namespace_commit_email)
+ ServiceResponse.success(payload: {
+ namespace_commit_email: namespace_commit_email
+ })
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_in_save(namespace_commit_email)
+ return error(_('Failed to save namespace commit email.')) if namespace_commit_email.errors.empty?
+
+ error(namespace_commit_email.errors.full_messages.to_sentence)
+ end
+ end
+end
diff --git a/app/services/webauthn/destroy_service.rb b/app/services/webauthn/destroy_service.rb
new file mode 100644
index 00000000000..afad2680d42
--- /dev/null
+++ b/app/services/webauthn/destroy_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Webauthn
+ class DestroyService < BaseService
+ attr_reader :webauthn_registration, :user, :current_user
+
+ def initialize(current_user, user, webauthn_registrations_id)
+ @current_user = current_user
+ @user = user
+ @webauthn_registration = user.webauthn_registrations.find(webauthn_registrations_id)
+ end
+
+ def execute
+ return error(_('You are not authorized to perform this action')) unless authorized?
+
+ webauthn_registration.destroy
+ user.reset_backup_codes! if last_two_factor_registration?
+ end
+
+ private
+
+ def last_two_factor_registration?
+ user.webauthn_registrations.empty? && !user.otp_required_for_login?
+ end
+
+ def authorized?
+ current_user.can?(:disable_two_factor, user)
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/award_emoji.rb b/app/services/work_items/callbacks/award_emoji.rb
new file mode 100644
index 00000000000..6344813d4b9
--- /dev/null
+++ b/app/services/work_items/callbacks/award_emoji.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class AwardEmoji < Base
+ def before_update
+ return unless params.present? && params.key?(:name) && params.key?(:action)
+ return unless has_permission?(:award_emoji)
+
+ execute_emoji_service(params[:action], params[:name])
+ end
+
+ private
+
+ def execute_emoji_service(action, name)
+ class_name = {
+ add: ::AwardEmojis::AddService,
+ remove: ::AwardEmojis::DestroyService
+ }
+
+ raise_error(invalid_action_error(action)) unless class_name.key?(action)
+
+ result = class_name[action].new(work_item, name, current_user).execute
+
+ raise_error(result[:message]) if result[:status] == :error
+ end
+
+ def invalid_action_error(key)
+ format(_("%{key} is not a valid action."), key: key)
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/callbacks/base.rb b/app/services/work_items/callbacks/base.rb
new file mode 100644
index 00000000000..c91e2b37d10
--- /dev/null
+++ b/app/services/work_items/callbacks/base.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Callbacks
+ class Base < Issuable::Callbacks::Base
+ alias_method :work_item, :issuable
+
+ def raise_error(message)
+ raise ::WorkItems::Widgets::BaseService::WidgetError, message
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb
index ae09e44b952..ba22b170a28 100644
--- a/app/services/work_items/create_and_link_service.rb
+++ b/app/services/work_items/create_and_link_service.rb
@@ -6,12 +6,12 @@ module WorkItems
# This class should always be run inside a transaction as we could end up with
# new work items that were never associated with other work items as expected.
class CreateAndLinkService
- def initialize(project:, spam_params:, current_user: nil, params: {}, link_params: {})
+ def initialize(project:, perform_spam_check: true, current_user: nil, params: {}, link_params: {})
@project = project
@current_user = current_user
@params = params
@link_params = link_params
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
end
def execute
@@ -19,7 +19,7 @@ module WorkItems
container: @project,
current_user: @current_user,
params: @params.merge(title: @params[:title].strip).reverse_merge(confidential: confidential_parent),
- spam_params: @spam_params
+ perform_spam_check: @perform_spam_check
).execute
return create_result if create_result.error?
diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb
index ced5b17a21c..25ec3169fe7 100644
--- a/app/services/work_items/create_from_task_service.rb
+++ b/app/services/work_items/create_from_task_service.rb
@@ -2,11 +2,11 @@
module WorkItems
class CreateFromTaskService
- def initialize(work_item:, spam_params:, current_user: nil, work_item_params: {})
+ def initialize(work_item:, perform_spam_check: true, current_user: nil, work_item_params: {})
@work_item = work_item
@current_user = current_user
@work_item_params = work_item_params
- @spam_params = spam_params
+ @perform_spam_check = perform_spam_check
@errors = []
end
@@ -16,7 +16,7 @@ module WorkItems
project: @work_item.project,
current_user: @current_user,
params: @work_item_params.slice(:title, :work_item_type_id),
- spam_params: @spam_params,
+ perform_spam_check: @perform_spam_check,
link_params: { parent_work_item: @work_item }
).execute
diff --git a/app/services/work_items/create_service.rb b/app/services/work_items/create_service.rb
index ae355dc6d96..903736cf662 100644
--- a/app/services/work_items/create_service.rb
+++ b/app/services/work_items/create_service.rb
@@ -5,12 +5,12 @@ module WorkItems
extend ::Gitlab::Utils::Override
include WidgetableService
- def initialize(container:, spam_params:, current_user: nil, params: {}, widget_params: {})
+ def initialize(container:, perform_spam_check: true, current_user: nil, params: {}, widget_params: {})
super(
container: container,
current_user: current_user,
params: params,
- spam_params: spam_params,
+ perform_spam_check: perform_spam_check,
build_service: ::WorkItems::BuildService.new(container: container, current_user: current_user, params: params)
)
@widget_params = widget_params
diff --git a/app/services/work_items/delete_task_service.rb b/app/services/work_items/delete_task_service.rb
index 3d66716543a..4c0ee2f827d 100644
--- a/app/services/work_items/delete_task_service.rb
+++ b/app/services/work_items/delete_task_service.rb
@@ -22,7 +22,7 @@ module WorkItems
current_user: @current_user
).execute
- break ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error?
+ next ::ServiceResponse.error(message: replacement_result.errors, http_status: 422) if replacement_result.error?
delete_result = ::WorkItems::DeleteService.new(
container: @task.project,
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index defdeebfed8..27b318d280f 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -5,10 +5,10 @@ module WorkItems
extend Gitlab::Utils::Override
include WidgetableService
- def initialize(container:, current_user: nil, params: {}, spam_params: nil, widget_params: {})
+ def initialize(container:, current_user: nil, params: {}, perform_spam_check: false, widget_params: {})
params[:widget_params] = true if widget_params.present?
- super(container: container, current_user: current_user, params: params, spam_params: spam_params)
+ super(container: container, current_user: current_user, params: params, perform_spam_check: perform_spam_check)
@widget_params = widget_params
end
@@ -59,6 +59,7 @@ module WorkItems
super
end
+ override :after_update
def after_update(work_item, old_associations)
super
diff --git a/app/services/work_items/widgets/award_emoji_service/update_service.rb b/app/services/work_items/widgets/award_emoji_service/update_service.rb
deleted file mode 100644
index 7c58c0c9af9..00000000000
--- a/app/services/work_items/widgets/award_emoji_service/update_service.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-module WorkItems
- module Widgets
- module AwardEmojiService
- class UpdateService < WorkItems::Widgets::BaseService
- def before_update_in_transaction(params:)
- return unless params.present? && params.key?(:name) && params.key?(:action)
- return unless has_permission?(:award_emoji)
-
- service_response!(service_result(params[:action], params[:name]))
- end
-
- private
-
- def service_result(action, name)
- class_name = {
- add: ::AwardEmojis::AddService,
- remove: ::AwardEmojis::DestroyService
- }
-
- return invalid_action_error(action) unless class_name.key?(action)
-
- class_name[action].new(work_item, name, current_user).execute
- end
-
- def invalid_action_error(key)
- error(format(_("%{key} is not a valid action."), key: key))
- end
- end
- end
- end
-end
diff --git a/app/uploaders/ci/secure_file_uploader.rb b/app/uploaders/ci/secure_file_uploader.rb
index 09d9b3abafb..85a285ff581 100644
--- a/app/uploaders/ci/secure_file_uploader.rb
+++ b/app/uploaders/ci/secure_file_uploader.rb
@@ -6,6 +6,10 @@ module Ci
storage_location :ci_secure_files
+ # TODO: Remove this line
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/232917
+ alias_method :upload, :model
+
# Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks)
encrypt(key: :key)
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index 2eb34288bd7..06bf742a22d 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -189,10 +189,10 @@ class GitlabUploader < CarrierWave::Uploader::Base
#
# @param [CarrierWave::SanitizedFile]
# @return [Nil]
- # @raise [Gitlab::Utils::PathTraversalAttackError]
+ # @raise [Gitlab::PathTraversal::PathTraversalAttackError]
def protect_from_path_traversal!(file)
PROTECTED_METHODS.each do |method|
- Gitlab::Utils.check_path_traversal!(self.send(method)) # rubocop: disable GitlabSecurity/PublicSend
+ Gitlab::PathTraversal.check_path_traversal!(self.send(method)) # rubocop: disable GitlabSecurity/PublicSend
rescue ObjectNotReadyError
# Do nothing. This test was attempted before the file was ready for that method
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 0a30f0e99f7..672433ec534 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -11,6 +11,7 @@ module ObjectStorage
RemoteStoreError = Class.new(StandardError)
UnknownStoreError = Class.new(StandardError)
ObjectStorageUnavailable = Class.new(StandardError)
+ MissingFinalStorePathRootId = Class.new(StandardError)
class ExclusiveLeaseTaken < StandardError
def initialize(lease_key)
@@ -153,21 +154,30 @@ module ObjectStorage
[CarrierWave.generate_cache_id, SecureRandom.hex].join('-')
end
- def generate_final_store_path
+ def generate_final_store_path(root_id:)
hash = Digest::SHA2.hexdigest(SecureRandom.uuid)
# We prefix '@final' to prevent clashes and make the files easily recognizable
# as having been created by this code.
- File.join('@final', hash[0..1], hash[2..3], hash[4..])
+ sub_path = File.join('@final', hash[0..1], hash[2..3], hash[4..])
+
+ # We generate a hashed path of the root ID (e.g. Project ID) to distribute directories instead of
+ # filling up one root directory with a bunch of files.
+ Gitlab::HashedPath.new(sub_path, root_hash: root_id).to_s
end
- def workhorse_authorize(has_length:, maximum_size: nil, use_final_store_path: false)
+ def workhorse_authorize(
+ has_length:,
+ maximum_size: nil,
+ use_final_store_path: false,
+ final_store_path_root_id: nil)
{}.tap do |hash|
if self.direct_upload_to_object_store?
hash[:RemoteObject] = workhorse_remote_upload_options(
has_length: has_length,
maximum_size: maximum_size,
- use_final_store_path: use_final_store_path
+ use_final_store_path: use_final_store_path,
+ final_store_path_root_id: final_store_path_root_id
)
else
hash[:TempPath] = workhorse_local_upload_path
@@ -190,11 +200,17 @@ module ObjectStorage
ObjectStorage::Config.new(object_store_options)
end
- def workhorse_remote_upload_options(has_length:, maximum_size: nil, use_final_store_path: false)
+ def workhorse_remote_upload_options(
+ has_length:,
+ maximum_size: nil,
+ use_final_store_path: false,
+ final_store_path_root_id: nil)
return unless direct_upload_to_object_store?
if use_final_store_path
- id = generate_final_store_path
+ raise MissingFinalStorePathRootId unless final_store_path_root_id.present?
+
+ id = generate_final_store_path(root_id: final_store_path_root_id)
upload_path = with_bucket_prefix(id)
prepare_pending_direct_upload(id)
else
@@ -410,7 +426,7 @@ module ObjectStorage
end
def retrieve_from_store!(identifier)
- Gitlab::Utils.check_path_traversal!(identifier)
+ Gitlab::PathTraversal.check_path_traversal!(identifier)
# We need to force assign the value of @filename so that we will still
# get the original_filename in cases wherein the file points to a random generated
diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb
index 45ac695c5ec..ff390a624c5 100644
--- a/app/validators/abstract_path_validator.rb
+++ b/app/validators/abstract_path_validator.rb
@@ -26,11 +26,22 @@ class AbstractPathValidator < ActiveModel::EachValidator
return
end
- full_path = record.build_full_path
- return unless full_path
+ if build_full_path_to_validate_against_reserved_names?
+ path_to_validate_against_reserved_names = record.build_full_path
+ return unless path_to_validate_against_reserved_names
+ else
+ path_to_validate_against_reserved_names = value
+ end
- unless self.class.valid_path?(full_path)
+ unless self.class.valid_path?(path_to_validate_against_reserved_names)
record.errors.add(attribute, "#{value} is a reserved name")
end
end
+
+ def build_full_path_to_validate_against_reserved_names?
+ # By default, entities using the `Routable` concern can build full paths.
+ # But entities like `Organization` do not have a parent, and hence cannot build full paths,
+ # and this method can be overridden to return `false` in such cases.
+ true
+ end
end
diff --git a/app/validators/json_schemas/abuse_event_metadata.json b/app/validators/json_schemas/abuse_event_metadata.json
new file mode 100644
index 00000000000..b24ec93f877
--- /dev/null
+++ b/app/validators/json_schemas/abuse_event_metadata.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Metadata to support an abuse event",
+ "type": "object",
+ "properties": {
+ }
+}
diff --git a/app/validators/json_schemas/abuse_report_evidence.json b/app/validators/json_schemas/abuse_report_evidence.json
new file mode 100644
index 00000000000..e00628d5704
--- /dev/null
+++ b/app/validators/json_schemas/abuse_report_evidence.json
@@ -0,0 +1,107 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Evidence to support an abuse report",
+ "type": "object",
+ "properties": {
+ "issues": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "title",
+ "description"
+ ]
+ }
+ },
+ "snippets": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "content"
+ ]
+ }
+ },
+ "notes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "content"
+ ]
+ }
+ },
+ "user": {
+ "type": "object",
+ "properties": {
+ "login_count": {
+ "type": "integer"
+ },
+ "account_age": {
+ "type": "integer"
+ },
+ "spam_score": {
+ "type": "number"
+ },
+ "telesign_score": {
+ "type": "number"
+ },
+ "arkos_score": {
+ "type": "number"
+ },
+ "pvs_score": {
+ "type": "number"
+ },
+ "product_coverage": {
+ "type": "number"
+ },
+ "virus_total_score": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "login_count",
+ "account_age",
+ "spam_score",
+ "telesign_score",
+ "arkos_score",
+ "pvs_score",
+ "product_coverage",
+ "virus_total_score"
+ ]
+ }
+ },
+ "required": [
+ "user"
+ ]
+}
diff --git a/app/validators/json_schemas/ci_job_annotation_data.json b/app/validators/json_schemas/ci_job_annotation_data.json
new file mode 100644
index 00000000000..d623ed3c179
--- /dev/null
+++ b/app/validators/json_schemas/ci_job_annotation_data.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Build annotation",
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "external_link": {
+ "$ref": "./ci_job_external_link_data.json"
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
+ },
+ "maxItems": 1000
+}
diff --git a/app/validators/json_schemas/ci_job_external_link_data.json b/app/validators/json_schemas/ci_job_external_link_data.json
new file mode 100644
index 00000000000..7f420963432
--- /dev/null
+++ b/app/validators/json_schemas/ci_job_external_link_data.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Build annotation external link",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/default_branch_protection_defaults.json b/app/validators/json_schemas/default_branch_protection_defaults.json
new file mode 100644
index 00000000000..bd2945c08fb
--- /dev/null
+++ b/app/validators/json_schemas/default_branch_protection_defaults.json
@@ -0,0 +1,76 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Default settings for default branch protection",
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "allow_force_push": {
+ "type": "boolean"
+ },
+ "allowed_to_merge": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "group_id": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "access_level": {
+ "type": "integer"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "allowed_to_push": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "group_id": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "access_level": {
+ "type": "integer"
+ }
+ }
+ }
+ ]
+ }
+ },
+ "code_owner_approval_required": {
+ "type": "boolean"
+ },
+ "merge_access_level": {
+ "type": "integer"
+ },
+ "push_access_level": {
+ "type": "integer"
+ },
+ "unprotect_access_level": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/plan_limits_history.json b/app/validators/json_schemas/plan_limits_history.json
new file mode 100644
index 00000000000..80d4165018a
--- /dev/null
+++ b/app/validators/json_schemas/plan_limits_history.json
@@ -0,0 +1,115 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "enforcement_limit": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "user_id",
+ "username",
+ "timestamp",
+ "value"
+ ]
+ }
+ },
+ "notification_limit": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "user_id",
+ "username",
+ "timestamp",
+ "value"
+ ]
+ }
+ },
+ "storage_size_limit": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "user_id",
+ "username",
+ "timestamp",
+ "value"
+ ]
+ }
+ },
+ "dashboard_limit_enabled_at": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "user_id": {
+ "type": "integer"
+ },
+ "username": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "integer"
+ },
+ "value": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "user_id",
+ "username",
+ "timestamp",
+ "value"
+ ]
+ }
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/app/validators/json_schemas/position.json b/app/validators/json_schemas/position.json
index d2c83be7639..4cbd4196c61 100644
--- a/app/validators/json_schemas/position.json
+++ b/app/validators/json_schemas/position.json
@@ -146,6 +146,12 @@
{ "type": "integer" },
{ "type": "string", "maxLength": 10 }
]
+ },
+ "ignore_whitespace_change": {
+ "oneOf": [
+ { "type": "null" },
+ { "type": "boolean" }
+ ]
}
}
}
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
index 0094d6156a3..3eb7aa4b652 100644
--- a/app/validators/key_restriction_validator.rb
+++ b/app/validators/key_restriction_validator.rb
@@ -31,7 +31,7 @@ class KeyRestrictionValidator < ActiveModel::EachValidator
sizes << "allowed" if valid_restriction?(ALLOWED)
sizes += self.class.supported_sizes(options[:type])
- Gitlab::Utils.to_exclusive_sentence(sizes)
+ Gitlab::Sentence.to_exclusive_sentence(sizes)
end
def valid_restriction?(value)
diff --git a/app/validators/organizations/path_validator.rb b/app/validators/organizations/path_validator.rb
new file mode 100644
index 00000000000..a1c22654a32
--- /dev/null
+++ b/app/validators/organizations/path_validator.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Organizations
+ class PathValidator < ::NamespacePathValidator
+ def self.path_regex
+ Gitlab::PathRegex.organization_path_regex
+ end
+
+ def build_full_path_to_validate_against_reserved_names?
+ # full paths cannot be built for organizations because organizations do not have a parent
+ # and it does not include the `Routable` concern.
+ false
+ end
+ end
+end
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index df08a1123c7..d29fa9c5b85 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -9,21 +9,21 @@
= f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold'
= f.number_field :default_projects_limit, class: 'form-control gl-form-input', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' }
.form-group
- = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold'
+ = f.label :max_attachment_size, _('Maximum attachment size (MiB)'), class: 'label-bold'
= f.number_field :max_attachment_size, class: 'form-control gl-form-input', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' }
= render 'admin/application_settings/repository_size_limit_setting_registration_features_cta', form: f
= render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f
.form-group
- = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light'
+ = f.label :receive_max_input_size, _('Maximum push size (MiB)'), class: 'label-light'
= f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' }
.form-group
- = f.label :max_export_size, _('Maximum export size (MB)'), class: 'label-light'
+ = f.label :max_export_size, _('Maximum export size (MiB)'), class: 'label-light'
= f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted= _('Set to 0 for no size limit.')
.form-group
- = f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light'
+ = f.label :max_import_size, _('Maximum import size (MiB)'), class: 'label-light'
= f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.')
.form-group
diff --git a/app/views/admin/application_settings/_ai_access.html.haml b/app/views/admin/application_settings/_ai_access.html.haml
new file mode 100644
index 00000000000..41b0a08128e
--- /dev/null
+++ b/app/views/admin/application_settings/_ai_access.html.haml
@@ -0,0 +1,32 @@
+- return if Gitlab.org_or_com?
+
+- expanded = integration_expanded?('ai_access')
+- token_is_present = @application_setting.ai_access_token.present?
+- token_label = token_is_present ? s_('CodeSuggestionsSM|Enter new personal access token') : s_('CodeSuggestionsSM|Personal access token')
+- token_value = token_is_present ? ApplicationSettingMaskedAttrs::MASK : ''
+
+%section.settings.no-animate#js-ai-access-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = s_('CodeSuggestionsSM|Code Suggestions')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = s_('CodeSuggestionsSM|Enable Code Suggestion for users of this GitLab instance.')
+ = link_to sprite_icon('question-o'), code_suggestions_docs_url, target: '_blank', class: 'has-tooltip', title: _('More information')
+
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-ai-access-settings'), html: { class: 'fieldset-form', id: 'ai-access-settings' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :instance_level_code_suggestions_enabled,
+ s_('CodeSuggestionsSM|Turn on Code Suggestions for this instance. By turning on this feature, you:'),
+ help_text: code_suggestions_agreement
+ = f.label :ai_access_token, token_label, class: 'label-bold'
+ = f.password_field :ai_access_token, value: token_value, autocomplete: 'on', class: 'form-control gl-form-input', aria: { describedby: 'code_suggestions_token_explanation' }
+ %p.form-text.text-muted{ id: 'code_suggestions_token_explanation' }
+ = code_suggestions_token_explanation
+
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_diagramsnet.html.haml b/app/views/admin/application_settings/_diagramsnet.html.haml
new file mode 100644
index 00000000000..e493110a9dc
--- /dev/null
+++ b/app/views/admin/application_settings/_diagramsnet.html.haml
@@ -0,0 +1,25 @@
+- expanded = integration_expanded?('diagramsnet_')
+%section.settings.as-diagramsnet.no-animate#js-diagramsnet-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = _('Diagrams.net')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Render diagrams in your documents using diagrams.net.')
+ = link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-diagramsnet-settings'), html: { class: 'fieldset-form', id: 'diagramsnet-settings' } do |f|
+ = form_errors(@application_setting) if expanded
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :diagramsnet_enabled,
+ _('Enable diagrams.net')
+ .form-group
+ = f.label :diagramsnet_url, _('Diagrams.net URL'), class: 'label-bold'
+ = f.text_field :diagramsnet_url, class: 'form-control gl-form-input', placeholder: 'https://embed.diagrams.net'
+ .form-text.text-muted
+ = _('The hostname of your diagrams.net server.')
+
+ = f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml
index 2e8eb25b1d5..153600f1299 100644
--- a/app/views/admin/application_settings/_diff_limits.html.haml
+++ b/app/views/admin/application_settings/_diff_limits.html.haml
@@ -3,7 +3,7 @@
%fieldset
.form-group
- = f.label :diff_max_patch_bytes, _('Maximum diff patch size (Bytes)'), class: 'label-light'
+ = f.label :diff_max_patch_bytes, _('Maximum diff patch size (bytes)'), class: 'label-light'
= f.number_field :diff_max_patch_bytes, class: 'form-control gl-form-input'
%span.form-text.text-muted
= _("Diff files surpassing this limit will be presented as 'too large' and won't be expandable.")
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 97d9426581e..1eb6b747704 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -16,7 +16,7 @@
s_("AdminSettings|Disable public access to Pages sites"),
help_text: s_("AdminSettings|Select to disable public access for Pages sites, which requires users to sign in for access to the Pages sites in your instance. %{link_start}Learn more.%{link_end}").html_safe % { link_start: pages_link_start, link_end: '</a>'.html_safe }
.form-group
- = f.label :max_pages_size, _('Maximum size of pages (MB)'), class: 'label-bold'
+ = f.label :max_pages_size, _('Maximum size of pages (MiB)'), class: 'label-bold'
= f.number_field :max_pages_size, class: 'form-control gl-form-input'
.form-text.text-muted
- pages_link_url = help_page_path('administration/pages/index', anchor: 'set-maximum-size-of-gitlab-pages-site-in-a-project')
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index 86a01e1785e..bfa548b70e5 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -11,16 +11,16 @@
= f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold'
= f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.')
+ = _('Maximum number of requests per minute for each raw path (default is `300`). Set to `0` to disable throttling.')
.form-group
= f.label :push_event_hooks_limit, class: 'label-bold'
= f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Maximum number of changes (branches or tags) in a single push for which webhooks and services trigger (default is 3).')
+ = _('Maximum number of changes (branches or tags) in a single push above which webhooks and integrations are not triggered (default is `3`). Setting to `0` does not disable throttling.')
.form-group
= f.label :push_event_activities_limit, class: 'label-bold'
= f.number_field :push_event_activities_limit, class: 'form-control gl-form-input'
.form-text.text-muted
- = _('Threshold number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3).')
+ = _('Maximum number of changes (branches or tags) in a single push above which a bulk push event is created (default is `3`). Setting to `0` does not disable throttling.')
= f.submit _('Save changes'), pajamas_button: true
diff --git a/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml
index 8daa5aa8c73..040a22ff2ac 100644
--- a/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml
+++ b/app/views/admin/application_settings/_repository_size_limit_setting_registration_features_cta.html.haml
@@ -2,7 +2,7 @@
.form-group
= form.label :disabled_repository_size_limit, class: 'label-bold' do
- = _('Size limit per repository (MB)')
+ = _('Size limit per repository (MiB)')
= form.number_field :disabled_repository_size_limit, value: '', class: 'form-control gl-form-input', disabled: true
%span.form-text.text-muted
= render 'shared/registration_features_discovery_message'
diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml
index 50b5e797559..85841059c5e 100644
--- a/app/views/admin/application_settings/_signin.html.haml
+++ b/app/views/admin/application_settings/_signin.html.haml
@@ -29,7 +29,7 @@
.form-text.text-muted
= _('Maximum time that users are allowed to skip the setup of two-factor authentication (in hours). Set to 0 (zero) to enforce at next sign in.')
.form-group
- = f.label :admin_mode, _('Admin Mode'), class: 'label-bold'
+ = f.label :admin_mode, _('Admin mode'), class: 'label-bold'
= sprite_icon('lock', css_class: 'gl-icon')
- help_text = _('Require additional authentication for administrative tasks.')
- help_link = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sign_in_restrictions', anchor: 'admin-mode'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/admin/application_settings/_user_restrictions.html.haml b/app/views/admin/application_settings/_user_restrictions.html.haml
index c35056383fa..c21d1ec47e6 100644
--- a/app/views/admin/application_settings/_user_restrictions.html.haml
+++ b/app/views/admin/application_settings/_user_restrictions.html.haml
@@ -5,3 +5,4 @@
= render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: form
= form.gitlab_ui_checkbox_component :can_create_group, _("Allow new users to create top-level groups")
= form.gitlab_ui_checkbox_component :user_defaults_to_private_profile, _("Make new users' profiles private by default")
+ = render_if_exists 'admin/application_settings/allow_account_deletion', form: form
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index e6c27c1bc84..022930bd6b4 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -94,6 +94,7 @@
= render 'admin/application_settings/kroki'
= render 'admin/application_settings/mailgun'
= render 'admin/application_settings/plantuml'
+= render 'admin/application_settings/diagramsnet'
= render 'admin/application_settings/sourcegraph'
= render_if_exists 'admin/application_settings/slack'
-# this partial is from JiHu, see details in https://jihulab.com/gitlab-cn/gitlab/-/merge_requests/417
@@ -108,3 +109,4 @@
= render 'admin/application_settings/floc'
= render_if_exists 'admin/application_settings/add_license'
= render 'admin/application_settings/jira_connect'
+= render_if_exists 'admin/application_settings/ai_access'
diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml
index e42c1091bf2..24f132b982a 100644
--- a/app/views/admin/application_settings/service_usage_data.html.haml
+++ b/app/views/admin/application_settings/service_usage_data.html.haml
@@ -25,7 +25,7 @@
- c.with_body do
- enable_service_ping_link_url = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics')
- enable_service_ping_link = '<a href="%{url}">'.html_safe % { url: enable_service_ping_link_url }
- - generate_manually_link_url = help_page_path('development/service_ping/troubleshooting', anchor: 'generate-service-ping')
+ - generate_manually_link_url = help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping')
- generate_manually_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_manually_link_url }
= html_escape(s_('%{enable_service_ping_link_start}Enable%{link_end} or %{generate_manually_link_start}generate%{link_end} Service Ping to preview and download service usage data payload.')) % { enable_service_ping_link_start: enable_service_ping_link, generate_manually_link_start: generate_manually_link, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml
index 7b00019cc21..c5632e0d70b 100644
--- a/app/views/admin/jobs/index.html.haml
+++ b/app/views/admin/jobs/index.html.haml
@@ -10,8 +10,10 @@
- else
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
- build_path_proc = ->(scope) { admin_jobs_path(scope: scope) }
= render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 8680bae5207..e643ec040a1 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -10,7 +10,7 @@
.labels.labels-container.admin-labels.js-admin-labels-container.gl-mt-4
.other-labels.gl-rounded-base.gl-border.gl-bg-gray-10
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
%h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
= _('Labels')
%ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index e942a513166..31ec4935f64 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -4,8 +4,10 @@
.top-area.gl-flex-direction-column-reverse
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full nav gl-tabs-nav nav gl-tabs-nav' }) do
= gl_tab_link_to _('All'), admin_projects_path(visibility_level: nil), { item_active: params[:visibility_level].empty? }
= gl_tab_link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 13c647cd45f..d0ee3acf0b8 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -3,5 +3,5 @@
= label_tag :user_password, _('Password'), class: 'label-bold'
= password_field_tag 'user[password]', nil, { class: 'form-control js-password', data: { id: 'user_password', name: 'user[password]', qa_selector: 'password_field', testid: 'password-field' } }
- .submit-container.move-submit-down
+ .submit-container
= submit_tag _('Enter admin mode'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
index 15005bb9224..70cad880293 100644
--- a/app/views/admin/sessions/_signin_box.html.haml
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -2,7 +2,7 @@
- if crowd_enabled?
.login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) }
.login-body
- = render 'devise/sessions/new_crowd'
+ = render 'devise/sessions/new_crowd', render_remember_me: false, submit_message: _('Enter admin mode')
- ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) }
diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml
index f7b4035488d..a27dea52884 100644
--- a/app/views/admin/sessions/_two_factor_otp.html.haml
+++ b/app/views/admin/sessions/_two_factor_otp.html.haml
@@ -5,5 +5,5 @@
%p.form-text.text-muted.hint
= _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.")
- .submit-container.move-submit-down
+ .submit-container
= submit_tag 'Verify code', class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 4fc30cbaecf..7301b0f6e04 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -8,7 +8,7 @@
- if any_form_based_providers_enabled?
= render 'devise/shared/tabs_ldap', show_password_form: allow_admin_mode_password_authentication_for_web?, render_signup_link: false
- else
- = render 'devise/shared/tab_single', tab_title: page_title
+ = render 'devise/shared/tab_single', tab_title: page_title if Feature.disabled?(:restyle_login_page, @project)
.tab-content
- if allow_admin_mode_password_authentication_for_web? || ldap_sign_in_enabled? || crowd_enabled?
= render 'admin/sessions/signin_box'
diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml
index 3bbf768d7be..bfe66e2477e 100644
--- a/app/views/admin/sessions/two_factor.html.haml
+++ b/app/views/admin/sessions/two_factor.html.haml
@@ -1,15 +1,14 @@
-- page_title _('Enter 2FA for Admin Mode')
+- page_title _('Two-factor authentication for admin mode')
- add_page_specific_style 'page_bundles/login'
.row.justify-content-center
.col-md-5.new-session-forms-container
.login-page
#signin-container{ class: ('borderless' if Feature.enabled?(:restyle_login_page, @project)) }
- = render 'devise/shared/tab_single', tab_title: _('Enter admin mode')
- .tab-content
- .login-box.tab-pane.gl-p-5.active{ id: 'login-pane', role: 'tabpanel' }
- .login-body
- - if current_user.two_factor_enabled?
- = render 'admin/sessions/two_factor_otp'
- - if current_user.two_factor_webauthn_enabled?
- = render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path
+ = render 'devise/shared/tab_single', tab_title: _('Enter admin mode') if Feature.disabled?(:restyle_login_page, @project)
+ .login-box.gl-p-5
+ .login-body
+ - if current_user.two_factor_enabled?
+ = render 'admin/sessions/two_factor_otp'
+ - if current_user.two_factor_webauthn_enabled?
+ = render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 183667679b9..6aed8508a6a 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -22,6 +22,8 @@
%td
= truncate(spam_log.description, length: 100)
%td
+ = moderation_status(user)
+ %td
- if user
= render Pajamas::ButtonComponent.new(size: :small,
variant: :danger,
diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml
index 001662c4015..9a0756510ec 100644
--- a/app/views/admin/spam_logs/index.html.haml
+++ b/app/views/admin/spam_logs/index.html.haml
@@ -14,6 +14,7 @@
%th= _('Type')
%th= _('Title')
%th= _('Description')
+ %th= _('User Status')
%th= _('Primary Action')
%th
= render @spam_logs
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
index 544310e312c..c3b5161d617 100644
--- a/app/views/admin/topics/_form.html.haml
+++ b/app/views/admin/topics/_form.html.haml
@@ -18,14 +18,15 @@
.form-group
= f.label :description, _("Description")
- = render layout: 'shared/md_preview', locals: { url: preview_markdown_admin_topics_path, referenced_users: false } do
- = render 'shared/zen', f: f, attr: :description,
- classes: 'note-textarea',
- placeholder: _('Write a description…'),
- supports_quick_actions: false,
- supports_autocomplete: false,
- qa_selector: 'topic_form_description'
- = render 'shared/notes/hints', supports_file_upload: false
+ .js-markdown-editor{ data: { render_markdown_path: preview_markdown_admin_topics_path,
+ markdown_docs_path: help_page_path('user/markdown'),
+ qa_selector: 'topic_form_description',
+ form_field_placeholder: _('Write a description…'),
+ supports_quick_actions: 'false',
+ enable_autocomplete: 'false',
+ disable_attachments: 'true',
+ form_field_classes: 'note-textarea js-gfm-input markdown-area' } }
+ = f.hidden_field :description
.form-group.gl-mt-3.gl-mb-3
= f.label :avatar, _('Topic avatar'), class: 'gl-display-block'
diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
index c63828cf41f..ce2b5ad793c 100644
--- a/app/views/admin/topics/_topic.html.haml
+++ b/app/views/admin/topics/_topic.html.haml
@@ -6,7 +6,7 @@
.gl-min-w-0.gl-flex-grow-1.gl-ml-3
.title
- = link_to title, topic_explore_projects_path(topic_name: topic.name)
+ = link_to title, topic_explore_projects_cleaned_path(topic_name: topic.name)
%div
= topic.name
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index c9264535a13..213d5847986 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -9,9 +9,9 @@
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full
- .fade-left
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
= sprite_icon('chevron-lg-left', size: 12)
- .fade-right
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
= sprite_icon('chevron-lg-right', size: 12)
= gl_tabs_nav({ class: 'scrolling-tabs nav-links gl-display-flex gl-flex-grow-1 gl-w-full' }) do
= gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do
diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml
index c8c970f3c2f..86219d5f021 100644
--- a/app/views/ci/group_variables/_index.html.haml
+++ b/app/views/ci/group_variables/_index.html.haml
@@ -1,14 +1,5 @@
-- variables = @project.group.self_and_ancestors.flat_map(&:variables)
-
-.ci-variable-table
- %table.gl-table.gl-w-full.gl-table-layout-fixed
- = render 'ci/group_variables/variable_header'
- - variables.each do |variable|
- %tr
- %td.gl-text-truncate
- = variable.key
- %td.gl-text-truncate
- = variable.environment_scope
- %td.gl-text-truncate
- %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) }
- = variable.group.name
+#js-inherited-group-ci-variables{
+ data: {
+ project_path: @project.full_path,
+ }
+}
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index 65e57d68288..f7ab495111a 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,12 +1,2 @@
= format(s_('CiVariables|Variables store information, like passwords and secret keys, that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe
= link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer'
-%p
- = _('Variables can have several attributes.')
- = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer'
-%ul
- %li
- = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected 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 }
- %li
- = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index da2c8a71dcd..5eed4e92386 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -2,6 +2,17 @@
- is_group = !@group.nil?
- is_project = !@project.nil?
+%p
+ = _('Variables can have several attributes.')
+ = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'define-a-cicd-variable-in-the-ui'), target: '_blank', rel: 'noopener noreferrer'
+%ul
+ %li
+ = html_escape(_('%{code_open}Protected:%{code_close} Only exposed to protected branches or protected 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 }
+ %li
+ = html_escape(_('%{code_open}Expanded:%{code_close} Variables with %{code_open}$%{code_close} will be treated as the start of a reference to another variable.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+
#js-ci-variables{ data: { endpoint: save_endpoint,
is_project: is_project.to_s,
project_id: @project&.id || '',
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 6461b71b10d..7d5d41c2851 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -8,12 +8,12 @@
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'hidden js-cluster-api-unreachable' }) do |c|
- = c.body do
+ - c.with_body do
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'hidden js-cluster-authentication-failure js-cluster-api-unreachable' }) do |c|
- = c.body do
+ - c.with_body do
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
.hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' }
diff --git a/app/views/clusters/clusters/_deprecation_alert.html.haml b/app/views/clusters/clusters/_deprecation_alert.html.haml
index 0318c0f7dfa..4f35ba78cc6 100644
--- a/app/views/clusters/clusters/_deprecation_alert.html.haml
+++ b/app/views/clusters/clusters/_deprecation_alert.html.haml
@@ -1,5 +1,5 @@
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mt-6 gl-mb-3' }) do |c|
- = c.body do
+ - c.with_body do
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- issue_link_start = link_start % { url: 'https://gitlab.com/gitlab-org/configure/general/-/issues/199' }
- docs_link_start = link_start % { url: help_page_path('user/clusters/agent/index.md') }
diff --git a/app/views/clusters/clusters/_details_tab.html.haml b/app/views/clusters/clusters/_details_tab.html.haml
index 734910686e7..75dc82527f2 100644
--- a/app/views/clusters/clusters/_details_tab.html.haml
+++ b/app/views/clusters/clusters/_details_tab.html.haml
@@ -1,4 +1,4 @@
- active = params[:tab] == 'details' || !params[:tab].present?
-= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active } do
+= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'details' }), { item_active: active, data: { testid: 'cluster-details-tab' } } do
= _('Details')
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 40632e27fa7..08badbb4963 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -4,8 +4,8 @@
alert_options: { class: 'gcp-signup-offer',
data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path }},
close_button_options: { data: { track_action: 'click_dismiss', track_label: 'gcp_signup_offer_banner' }}) do |c|
- = c.body do
+ - c.with_body do
= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- = c.actions do
+ - c.with_actions do
= render Pajamas::ButtonComponent.new(variant: :confirm, href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', button_options: { rel: 'noopener noreferrer', data: { track_action: 'click_button', track_label: 'gcp_signup_offer_banner' } }) do
= s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_health.html.haml b/app/views/clusters/clusters/_health.html.haml
deleted file mode 100644
index 9e7820d3136..00000000000
--- a/app/views/clusters/clusters/_health.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-- add_page_specific_style 'page_bundles/prometheus'
-
-%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health
- - if @cluster&.integration_prometheus_available?
- #prometheus-graphs{ data: @cluster.health_data(clusterable) }
-
- - else
- %p.settings-message.text-center= s_("ClusterIntegration|In order to view the health of your cluster, you must first enable Prometheus in the Integrations tab.")
diff --git a/app/views/clusters/clusters/_health_tab.html.haml b/app/views/clusters/clusters/_health_tab.html.haml
deleted file mode 100644
index 4292066cc6f..00000000000
--- a/app/views/clusters/clusters/_health_tab.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- active = params[:tab] == 'health'
-
-= gl_tab_link_to clusterable.cluster_path(@cluster.id, params: { tab: 'health' }), { item_active: active, data: { testid: 'cluster-health-tab' } } do
- = _('Health')
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 19ca9407513..57de6d980f8 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -32,7 +32,6 @@
= gl_tabs_nav do
= render 'clusters/clusters/details_tab'
= render_if_exists 'clusters/clusters/environments_tab'
- = render 'clusters/clusters/health_tab' if !Feature.enabled?(:remove_monitor_metrics)
= render 'clusters/clusters/integrations_tab' if !Feature.enabled?(:remove_monitor_metrics)
= render 'clusters/clusters/advanced_settings_tab'
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index e600d84f492..b72b252a852 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -12,8 +12,10 @@
.top-area
.scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-flex-basis-0.gl-min-w-0
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
= render 'dashboard/projects_nav'
.nav-controls
= render 'shared/projects/search_form'
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index 601b6a8b1a7..ea7cd75152d 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,2 +1,2 @@
.js-groups-list-holder
- #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ #js-groups-tree{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index de34c709ff3..658632b70a6 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,11 +1,19 @@
-- page_title _("Merge requests")
-- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
-- add_page_specific_style 'page_bundles/issuable_list'
+:ruby
+ title = if params[:reviewer_username] == current_user.username
+ _("Review requests")
+ elsif params[:assignee_username] == current_user.username
+ _("Assigned merge requests")
+ else
+ _("Merge requests")
+ end
+ page_title title
+ @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
+ add_page_specific_style 'page_bundles/issuable_list'
= render_dashboard_ultimate_trial(current_user)
.page-title-holder.d-flex.align-items-start.flex-column.flex-sm-row.align-items-sm-center
- %h1.page-title.gl-font-size-h-display= _('Merge requests')
+ %h1.page-title.gl-font-size-h-display= title
- if current_user
.page-title-controls.ml-0.mb-3.ml-sm-auto.mb-sm-0
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
index 855177fd836..94f956896d6 100644
--- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -4,7 +4,7 @@
- if has_start_trial?
= render_if_exists "dashboard/projects/blank_state_ee_trial"
- = link_to new_project_path, class: link_classes do
+ = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
.blank-state-body.gl-sm-pl-6
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index c5fdc31a775..08b914a218d 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -2,7 +2,7 @@
.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between
- if current_user.can_create_project?
- = link_to new_project_path, class: link_classes do
+ = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do
.blank-state-icon
= custom_icon("add_new_project", size: 50)
.blank-state-body.gl-sm-pl-6
@@ -12,7 +12,7 @@
= _('Projects are where you store your code, access issues, wiki and other features of GitLab.')
- else
= render Pajamas::AlertComponent.new(variant: :info, alert_options: { class: 'gl-mb-5 gl-w-full' }) do |c|
- = c.body do
+ - c.with_body do
= _("You see projects here when you're added to a group or project.").html_safe
- if current_user.can_create_group?
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index 5af247703f6..00652e8574a 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -15,7 +15,7 @@
= recaptcha_tags nonce: content_security_policy_nonce
.gl-mt-5
- = render Pajamas::ButtonComponent.new(block: true, type: :submit, variant: :confirm) do
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
= _("Resend")
.clearfix.prepend-top-20
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 498fb08969c..35ee9a7679a 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -1,7 +1,7 @@
= render 'devise/shared/tab_single', tab_title: _('Change your password')
.login-box
.login-body
- = form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors gl-pt-5' }) do |f|
.devise-errors.gl-px-5
= render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
@@ -13,7 +13,8 @@
= f.label _('Confirm new password'), for: "user_password_confirmation"
= f.password_field :password_confirmation, autocomplete: 'new-password', class: "form-control gl-form-input bottom", title: _('This field is required.'), data: { qa_selector: 'password_confirmation_field' }, required: true
.clearfix.gl-px-5.gl-pb-5
- = f.submit _("Change your password"), class: "gl-button btn btn-confirm", data: { qa_selector: 'change_password_button' }
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'change_password_button' } }) do
+ = _('Change your password')
.clearfix.prepend-top-20
%p
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index 1400ac9ca72..8e55977fe7a 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -1,20 +1,23 @@
.login-box
.login-body
- = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }}) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
- .form-group.gl-px-5.gl-pt-5
- = f.label :email, class: ("gl-mb-1" if Feature.enabled?(:restyle_login_page))
+ .form-group
+ = f.label :email, _('Email')
= f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.')
.form-text.text-muted
- = _('Requires your primary GitLab email address.')
+ = _('Requires a verified GitLab email address.')
- if recaptcha_enabled?
- .gl-px-5
+ .gl-mb-5
= recaptcha_tags nonce: content_security_policy_nonce
- .gl-p-5
- = f.submit _("Reset password"), class: "gl-button btn-confirm btn"
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
+ = _('Reset password')
-.clearfix.prepend-top-20
+- if Feature.enabled?(:restyle_login_page, @project)
= render 'devise/shared/sign_in_link'
+- else
+ .gl-mt-3
+ = render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index e75449bf320..18856e04eb8 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -7,6 +7,9 @@
= render "layouts/bizible"
= render "layouts/google_tag_manager_body"
+- content_for :omniauth_providers_bottom do
+ = render 'devise/shared/signup_omniauth_providers'
+
.signup-page
= render signup_box_template,
url: registration_path(resource_name, registration_path_params),
diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml
index 698e8c89a08..4825f192d4d 100644
--- a/app/views/devise/sessions/_new_base.html.haml
+++ b/app/views/devise/sessions/_new_base.html.haml
@@ -1,31 +1,27 @@
-= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
- .form-group.gl-px-5.gl-pt-5
- = render_if_exists 'devise/sessions/new_base_user_login_label', form: f
- = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input top js-username-field', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
- .form-group.gl-px-5
- = f.label :password, class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
+= gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'gl-p-5 gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }, data: { testid: 'sign-in-form' }}) do |f|
+ .form-group
+ = f.label :login, _('Username or email')
+ = f.text_field :login, value: @invite_email, class: 'form-control gl-form-input js-username-field', autocomplete: 'username', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field', testid: 'username-field' }
+ .form-group
+ = f.label :password, _('Password')
= f.password_field :password, class: 'form-control gl-form-input js-password', data: { id: "#{resource_name}_password",
qa_selector: 'password_field',
testid: 'password-field',
name: "#{resource_name}[password]" }
- .gl-px-5
- .gl-display-inline-block
- - if remember_me_enabled?
- = f.gitlab_ui_checkbox_component :remember_me, _('Remember me')
- .gl-float-right
+ .form-text.gl-text-right
- if unconfirmed_email?
= link_to _('Resend confirmation email'), new_user_confirmation_path
- else
= link_to _('Forgot your password?'), new_password_path(:user)
- %div
+
+ .form-group
- if Feature.enabled?(:arkose_labs_login_challenge)
= render_if_exists 'devise/sessions/arkose_labs'
- elsif captcha_enabled? || captcha_on_login_required?
- .gl-px-5
- = recaptcha_tags nonce: content_security_policy_nonce
+ = recaptcha_tags nonce: content_security_policy_nonce
+
+ - if remember_me_enabled?
+ = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' }
- .submit-container.move-submit-down.gl-px-5.gl-pb-5
- = f.button _('Sign in'), type: :submit, class: "gl-button btn btn-block btn-confirm js-sign-in-button#{' js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' }
- - if Gitlab::CurrentSettings.sign_in_text.present? && Feature.enabled?(:restyle_login_page, @project)
- .gl-px-5
- = markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do
+ = _('Sign in')
diff --git a/app/views/devise/sessions/_new_base_user_login_label.html.haml b/app/views/devise/sessions/_new_base_user_login_label.html.haml
deleted file mode 100644
index 8a8b9f7a361..00000000000
--- a/app/views/devise/sessions/_new_base_user_login_label.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= local_assigns[:form].label _('Username or email'), for: 'user_login', class: "label-bold #{'gl-mb-1' if Feature.enabled?(:restyle_login_page, @project)}"
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index 293e287371a..bb398eaf4be 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -1,13 +1,16 @@
-= form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user', class: 'gl-show-field-errors') do
- .form-group.gl-px-5.gl-pt-5
- = label_tag :username, _('Username or email')
- = text_field_tag :username, nil, { class: "form-control top", title: _("This field is required."), autofocus: "autofocus", required: true }
- .form-group.gl-px-5
- = label_tag :password
- = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password' } }
- - if remember_me_enabled?
- .remember-me.gl-px-5
- %label{ for: "remember_me" }
- = check_box_tag :remember_me, '1', false, id: 'remember_me'
- %span= _('Remember me')
- = submit_tag _("Sign in"), class: "gl-button btn-confirm btn gl-px-5"
+- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true)
+- submit_message = local_assigns.fetch(:submit_message, _('Sign in'))
+
+= gitlab_ui_form_for(:crowd, url: omniauth_authorize_path(:user, :crowd), html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }, data: { testid: 'new_crowd_user' }}) do |f|
+ .form-group
+ = f.label :username, _('Username or email')
+ = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', required: true
+ .form-group
+ = f.label :password, _('Password')
+ = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{:crowd}_password", name: 'password' }
+
+ - if render_remember_me
+ = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' }
+
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true) do
+ = submit_message
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index fb5a57b509c..f9b6f462661 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,19 +1,18 @@
- server = local_assigns.fetch(:server)
+- provider = server['provider_name']
- render_remember_me = remember_me_enabled? && local_assigns.fetch(:render_remember_me, true)
- submit_message = local_assigns.fetch(:submit_message, _('Sign in'))
-= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
- .form-group.gl-px-5.gl-pt-5
- = label_tag :username, "#{server['label']} Username"
- = text_field_tag :username, nil, { class: "form-control gl-form-input top", title: _("This field is required."), autofocus: "autofocus", data: { qa_selector: 'username_field' }, required: true }
- .form-group.gl-px-5
- = label_tag :password
- = password_field_tag :password, nil, { class: 'form-control gl-form-input js-password', data: { id: 'password', name: 'password', qa_selector: 'password_field' } }
+= gitlab_ui_form_for(provider, url: omniauth_callback_path(:user, provider), html: { class: 'gl-p-5 gl-show-field-errors', aria: { live: 'assertive' }, data: { testid: 'new_ldap_user' }}) do |f|
+ .form-group
+ = f.label :username, _('Username')
+ = f.text_field :username, name: :username, autocomplete: :username, class: 'form-control gl-form-input', title: _('This field is required.'), autofocus: 'autofocus', data: { qa_selector: 'username_field' }, required: true
+ .form-group
+ = f.label :password, _('Password')
+ = f.text_field :vue_password_placeholder, class: 'form-control gl-form-input js-password', data: { id: "#{provider}_password", name: 'password', qa_selector: 'password_field' }
+
- if render_remember_me
- .gl-px-5
- = render Pajamas::CheckboxTagComponent.new(name: 'remember_me') do |c|
- = c.label do
- = _('Remember me')
+ = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { name: :remember_me, autocomplete: 'off' }
- .submit-container.move-submit-down.gl-px-5.gl-pb-5
- = submit_tag submit_message, class: "gl-button btn btn-confirm", data: { qa_selector: 'sign_in_button' }
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'sign_in_button' } }) do
+ = submit_message
diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml
index 6cafcb941b4..e0b5a266961 100644
--- a/app/views/devise/sessions/email_verification.haml
+++ b/app/views/devise/sessions/email_verification.haml
@@ -2,7 +2,7 @@
= render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account')
.login-box.gl-p-5
.login-body
- = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f|
+ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f|
%p
= s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "<strong>#{sanitize(obfuscated_email(resource.email))}</strong>".html_safe }
%div
@@ -11,7 +11,7 @@
%p.gl-field-error.gl-mt-2
= resource.errors.full_messages.to_sentence
.gl-mt-5
- = f.submit s_('IdentityVerification|Verify code'), class: 'gl-button btn btn-confirm'
+ = f.submit s_('IdentityVerification|Verify code'), class: 'gl-w-full', pajamas_button: true
- unless send_rate_limited?(resource)
= link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0'
%p.gl-p-5.gl-text-secondary
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index 06152e3dac5..e3457040e6c 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,17 +1,20 @@
-%div
- = render 'devise/shared/tab_single', tab_title: _('Two-Factor Authentication') if Feature.disabled?(:restyle_login_page, @project)
- .login-box.gl-p-5
- .login-body
- - if @user.two_factor_enabled?
- = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f|
+= render 'devise/shared/tab_single', tab_title: _('Two-factor authentication') if Feature.disabled?(:restyle_login_page, @project)
+.login-box.gl-p-5
+ .login-body
+ - if @user.two_factor_enabled?
+ = gitlab_ui_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_enabled?}" }) do |f|
+ .form-group
+ = f.label :otp_attempt, _('Enter verification code')
+ = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
+ %p.form-text.text-muted.hint
+ = _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.")
+
+ - if remember_me_enabled?
- resource_params = params[resource_name].presence || params
- - if remember_me_enabled?
- = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
- %div
- = f.label _('Enter verification code'), name: :otp_attempt, class: Feature.enabled?(:restyle_login_page, @project) ? 'gl-mb-1' : ''
- = f.text_field :otp_attempt, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', inputmode: 'numeric', title: _('This field is required.'), data: { qa_selector: 'two_fa_code_field' }
- %p.form-text.text-muted.hint= _("Enter the code from your two-factor authenticator app. If you've lost your device, you can enter one of your recovery codes.")
- .prepend-top-20
- = f.submit _("Verify code"), pajamas_button: true, data: { qa_selector: 'verify_code_button' }
- - if @user.two_factor_webauthn_enabled?
- = render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
+ = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
+
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'verify_code_button' } }) do
+ = _("Verify code")
+
+ - if @user.two_factor_webauthn_enabled?
+ = render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
diff --git a/app/views/devise/shared/_error_messages.html.haml b/app/views/devise/shared/_error_messages.html.haml
index b7589a4460e..caebda72b9c 100644
--- a/app/views/devise/shared/_error_messages.html.haml
+++ b/app/views/devise/shared/_error_messages.html.haml
@@ -3,7 +3,7 @@
variant: :danger,
dismissible: false,
alert_options: { id: 'error_explanation', class: 'gl-mb-3'}) do |c|
- = c.body do
+ - c.with_body do
%ul.gl-pl-4
- resource.errors.full_messages.each do |message|
%li= message
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 14a9bde2d9e..8f2c2c58790 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -18,5 +18,5 @@
= label_for_provider(provider)
- if render_remember_me
= render Pajamas::CheckboxTagComponent.new(name: 'remember_me_omniauth', value: nil) do |c|
- = c.label do
+ - c.with_label do
= _('Remember me')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 31c541eebde..684ade87720 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,11 +1,9 @@
- max_first_name_length = max_last_name_length = 127
-- omniauth_providers_placement ||= :bottom
- borderless ||= false
- form_resource_name = "new_#{resource_name}"
.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
- - if show_omniauth_providers && omniauth_providers_placement == :top
- = render 'devise/shared/signup_omniauth_providers_top'
+ = yield :omniauth_providers_top if show_omniauth_providers
= gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'new_user gl-show-field-errors js-arkose-labs-form', 'aria-live' => 'assertive' }, data: { testid: 'signup-form' }) do |f|
.devise-errors
@@ -77,5 +75,5 @@
.gl-pt-5
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
= 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'
+
+ = yield :omniauth_providers_bottom if show_omniauth_providers
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
index 6294a93808b..e8c82e456ae 100644
--- a/app/views/devise/shared/_signup_omniauth_provider_list.haml
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -1,11 +1,10 @@
-- register_omniauth_params = { intent: :register }
- if Feature.enabled?(:restyle_login_page, @project)
.gl-text-center.gl-pt-5
%label.gl-font-weight-normal
= _("Register with:")
.gl-text-center.gl-ml-auto.gl-mr-auto
- providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
+ = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
@@ -15,7 +14,7 @@
= _("Create an account using:")
.gl-display-flex.gl-justify-content-between.gl-flex-wrap
- providers.each do |provider|
- = link_to omniauth_authorize_path(:user, provider, register_omniauth_params), method: :post, class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider }, id: "oauth-login-#{provider}" do
+ = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do
- if provider_has_icon?(provider)
= provider_image_tag(provider)
%span.gl-button-text
diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index d2a47974e01..5ec3c7a4150 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,4 +1,4 @@
- if Feature.disabled?(:restyle_login_page, @project)
.omniauth-divider.gl-display-flex.gl-align-items-center
= _("or")
-= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers
+= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers, tracking_label: "free_registration"
diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml
deleted file mode 100644
index 8eb22c0b023..00000000000
--- a/app/views/devise/shared/_signup_omniauth_providers_top.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-= render 'devise/shared/signup_omniauth_provider_list', providers: popular_enabled_button_based_providers
-.omniauth-divider.gl-display-flex.gl-align-items-center
- = _("or")
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index bb2bd193565..3291129fd69 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,3 +1,3 @@
.js-groups-list-holder
- #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
+ #js-groups-tree{ data: { endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
= gl_loading_icon(size: 'md', css_class: 'gl-mt-6')
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index e49b3eb7781..88d57ed7e33 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -3,5 +3,5 @@
- if current_user
- unless has_label
- %span.gl-float-left= _("Visibility:")
+ %span.gl-float-left.gl-white-space-nowrap= _("Visibility:")
= gl_redirect_listbox_tag(projects_filter_items, selected, class: 'gl-ml-3', data: { placement: 'right' })
diff --git a/app/views/explore/projects/_head.html.haml b/app/views/explore/projects/_head.html.haml
index 605d85f49e0..c1d37965cd6 100644
--- a/app/views/explore/projects/_head.html.haml
+++ b/app/views/explore/projects/_head.html.haml
@@ -3,7 +3,7 @@
= render_dashboard_ultimate_trial(current_user)
-.page-title-holder.gl-display-flex.gl-align-items-center
+.page-title-holder.gl-display-flex.gl-align-items-center{ data: { testid: 'explore-projects-title' } }
%h1.page-title.gl-font-size-h-display= page_title
.page-title-controls
- if current_user&.can_create_project?
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 80da61847ef..6b4832d81aa 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -13,7 +13,7 @@
.text-muted.gl-mb-5
= labels_function_introduction
.other-labels.gl-rounded-base.gl-border.gl-bg-gray-10
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
%h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
= _('Labels')
%ul.manage-labels-list.js-other-labels.gl-px-3.gl-rounded-base
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 21b1986bd34..d92a6b08b60 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -3,7 +3,7 @@
.sub-section
%h4.warning-title= s_('GroupSettings|Change group URL')
- = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
+ = gitlab_ui_form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@group)
.form-group
%p
@@ -23,7 +23,7 @@
title: group_url_error_message,
maxlength: ::Namespace::URL_MAX_LENGTH,
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- = f.submit s_('GroupSettings|Change group URL'), class: 'btn gl-button btn-danger'
+ = f.submit s_('GroupSettings|Change group URL'), class: 'btn-danger', pajamas_button: true
= render 'groups/settings/transfer', group: @group
= render 'groups/settings/remove', group: @group, remove_form_id: remove_form_id
diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml
index 9f04b579a97..74f9298133b 100644
--- a/app/views/groups/settings/_lfs.html.haml
+++ b/app/views/groups/settings/_lfs.html.haml
@@ -7,6 +7,6 @@
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :lfs_enabled,
- _('Allow projects within this group to use Git LFS'),
- help_text: _('Can be overridden in each project.'),
+ _('Projects in this group can use Git LFS'),
+ help_text: _('Possible to override in each project.'),
checkbox_options: { checked: @group.lfs_enabled?, data: { qa_selector: 'lfs_checkbox' } }
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 6fa76297679..f4749617463 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -30,8 +30,6 @@
help_text: s_('GroupSettings|Group members are not notified if the group is mentioned.')
= render 'groups/settings/resource_access_token_creation', f: f, group: @group
- - unless Feature.enabled?(:always_perform_delayed_deletion)
- = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group
= render 'groups/settings/ip_restriction_registration_features_cta', f: f
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
@@ -40,6 +38,7 @@
= render 'groups/settings/lfs', f: f
= render_if_exists 'groups/settings/code_suggestions', f: f, group: @group
= render_if_exists 'groups/settings/ai_related_settings', f: f, group: @group
+ = render_if_exists 'groups/settings/ai_third_party_settings', f: f, group: @group
= render 'groups/settings/git_access_protocols', f: f, group: @group
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index e42f524467d..6758598d4dd 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -19,7 +19,7 @@
= render partial: 'flash_messages'
-= render_if_exists 'trials/alert', namespace: @group
+= render_if_exists 'subscriptions/trials/alert', namespace: @group
= render 'groups/home_panel'
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index eb6d5668807..4b16c0199ba 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -1,4 +1,5 @@
- page_title _("IDE"), @project.full_name
+- add_page_specific_style 'page_bundles/web_ide_loader'
- unless use_new_web_ide?
- add_page_specific_style 'page_bundles/build'
@@ -9,4 +10,4 @@
- data = ide_data(project: @project, fork_info: @fork_info, params: params)
-= render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE...') }
+= render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE') }
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
index 2dbb54a9a0e..760715b56ea 100644
--- a/app/views/import/shared/_errors.html.haml
+++ b/app/views/import/shared/_errors.html.haml
@@ -2,6 +2,6 @@
= render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
- @errors.each do |error|
= error
diff --git a/app/views/layouts/_header_search.html.haml b/app/views/layouts/_header_search.html.haml
index 28118cf4aaa..1d5f2583bbd 100644
--- a/app/views/layouts/_header_search.html.haml
+++ b/app/views/layouts/_header_search.html.haml
@@ -1,4 +1,4 @@
-#js-header-search.header-search.is-not-active.gl-relative.gl-w-full{ data: { 'search-context' => header_search_context.to_json,
+#js-header-search.header-search-form.is-not-active.gl-relative.gl-w-full{ data: { 'search-context' => header_search_context.to_json,
'search-path' => search_path,
'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path,
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index b20b95cade8..1e6f671aacb 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -17,8 +17,6 @@
-# Do not use preload_link_tag for fonts, to work around Firefox double-fetch bug.
-# See https://github.com/web-platform-tests/wpt/pull/36930
%link{ rel: 'preload', href: font_path('gitlab-sans/GitLabSans.woff2'), as: 'font', crossorigin: css_crossorigin }
- %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono.woff2'), as: 'font', crossorigin: css_crossorigin }
- %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Bold.woff2'), as: 'font', crossorigin: css_crossorigin }
- %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin }
- %link{ rel: 'preload', href: font_path('jetbrains-mono/JetBrainsMono-BoldItalic.woff2'), as: 'font', crossorigin: css_crossorigin }
+ %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono.woff2'), as: 'font', crossorigin: css_crossorigin }
+ %link{ rel: 'preload', href: font_path('gitlab-mono/GitLabMono-Italic.woff2'), as: 'font', crossorigin: css_crossorigin }
= preload_link_tag(path_to_stylesheet('fonts'), crossorigin: css_crossorigin)
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index aa80de7f789..8e52f973e9e 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -20,6 +20,8 @@
.mobile-overlay
= dispensable_render_if_exists 'layouts/header/verification_reminder'
.alert-wrapper.gl-force-block-formatting-context
+ = yield :code_suggestions_third_party_alert
+ = dispensable_render 'shared/new_nav_announcement'
= dispensable_render 'shared/outdated_browser'
= dispensable_render_if_exists "layouts/header/licensed_user_count_threshold"
= dispensable_render_if_exists "layouts/header/token_expiry_notification"
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 71771dd7cb6..6e1d3ba678c 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,4 +1,5 @@
- add_page_specific_style 'page_bundles/login'
+- custom_text = custom_sign_in_description
!!! 5
%html.devise-layout-html{ class: system_message_class }
= render "layouts/head", { startup_filename: 'signin' }
@@ -10,14 +11,13 @@
.container.navless-container
.content
= render "layouts/flash"
- - if current_appearance&.description?
+ - if custom_text.present?
.row
.col-md.order-12.sm-bg-gray-10
.col-sm-12
%h1.mb-3.gl-font-size-h2
= brand_title
- = brand_text
- = render_if_exists 'layouts/devise_help_text'
+ = custom_text
.col-md.order-md-12
.col-sm-12.bar
.gl-text-center
@@ -29,7 +29,6 @@
= brand_image
%h1.mb-3.gl-font-size-h2
= brand_title
- = render_if_exists 'layouts/devise_help_text'
.mb-3
.gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar
= yield
@@ -49,8 +48,8 @@
.col-md-6.order-12.order-sm-1.brand-holder
- unless recently_confirmed_com?
= brand_image
- - if current_appearance&.description?
- = brand_text
+ - if custom_text.present?
+ = custom_text
- else
%h3.gl-sm-mt-0
= _('A complete DevOps platform')
@@ -61,11 +60,6 @@
%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-md-6.order-1.new-session-forms-container{ class: recently_confirmed_com? ? 'order-sm-first' : 'order-sm-12' }
= yield
diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml
index f4f9f39c20e..da192822902 100644
--- a/app/views/layouts/fullscreen.html.haml
+++ b/app/views/layouts/fullscreen.html.haml
@@ -17,7 +17,7 @@
= render "layouts/broadcast"
= yield :flash_message
= render "layouts/flash", flash_container_no_margin: true
- .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" }
+ .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch gl-p-0" }
= yield
- unless minimal
= render "layouts/nav/top_nav_responsive", class: "gl-flex-grow-1 gl-overflow-y-auto"
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 1f742279756..c75b02aa6a6 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -9,6 +9,7 @@
- content_for :flash_message do
= dispensable_render_if_exists "groups/storage_enforcement_alert", context: @group
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @group
+ = dispensable_render_if_exists "shared/namespace_combined_storage_users_alert", context: @group
- content_for :page_specific_javascripts do
- if current_user
@@ -20,6 +21,8 @@
= render 'groups/invite_members_modal', group: @group
= dispensable_render_if_exists "shared/web_hooks/group_web_hook_disabled_alert"
+= dispensable_render_if_exists "shared/code_suggestions_alert"
+= dispensable_render_if_exists "shared/code_suggestions_third_party_alert", source: @group
= dispensable_render_if_exists "shared/free_user_cap_alert", source: @group
= dispensable_render_if_exists "shared/unlimited_members_during_trial_alert", resource: @group
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 1739dee1511..65dbafc19da 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -42,9 +42,8 @@
%li.d-md-none
= link_to _("Switch to GitLab Next"), Gitlab::Saas.canary_toggle_com_url, data: { track_action: "click_link", track_label: "switch_to_canary", track_property: "navigation_top" }
- - if Feature.enabled?(:super_sidebar_nav, current_user)
- %li.divider
- .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} }
+ %li.divider
+ .js-new-nav-toggle{ data: { enabled: show_super_sidebar?.to_s, endpoint: profile_preferences_url} }
- if current_user_menu?(:sign_out)
%li.divider
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 7156a0e5931..2c6ccb4abaf 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -29,7 +29,7 @@
.navbar-collapse.gl-transition-medium.collapse.gl-mr-auto.global-search-container.hide-when-top-nav-responsive-open
- search_menu_item = top_nav_search_menu_item_attrs
%ul.nav.navbar-nav.gl-w-full.gl-align-items-center
- %li.nav-item.header-search-new.gl-display-none.gl-lg-display-block.gl-w-full
+ %li.nav-item.header-search.gl-display-none.gl-lg-display-block.gl-w-full
- unless current_controller?(:search)
= render 'layouts/header_search'
%li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
diff --git a/app/views/layouts/header/_registration_enabled_callout.html.haml b/app/views/layouts/header/_registration_enabled_callout.html.haml
index 5c70136a932..ee4644e9ff0 100644
--- a/app/views/layouts/header/_registration_enabled_callout.html.haml
+++ b/app/views/layouts/header/_registration_enabled_callout.html.haml
@@ -6,9 +6,9 @@
data: { feature_id: Users::CalloutsHelper::REGISTRATION_ENABLED_CALLOUT,
dismiss_endpoint: callouts_path }},
close_button_options: { data: { testid: 'close-registration-enabled-callout' }}) do |c|
- = c.body do
+ - c.with_body do
= _("Your GitLab instance allows anyone to register for an account, which is a security risk on public-facing GitLab instances. You should deactivate new sign ups if public users aren't expected to register for an account.")
- = c.actions do
+ - c.with_actions do
= render Pajamas::ButtonComponent.new(variant: :confirm, href: general_admin_application_settings_path(anchor: 'js-signup-settings')) do
= _('Deactivate')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-close gl-ml-3'}) do
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 31d02324e68..4ecae875056 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -10,6 +10,7 @@
- content_for :flash_message do
= dispensable_render_if_exists "projects/storage_enforcement_alert", context: @project
= dispensable_render_if_exists "shared/namespace_storage_limit_alert", context: @project
+ = dispensable_render_if_exists "shared/namespace_combined_storage_users_alert", context: @project
- content_for :project_javascripts do
- project = @target_project || @project
@@ -22,6 +23,8 @@
= render 'projects/invite_members_modal', project: @project
= dispensable_render_if_exists "shared/web_hooks/web_hook_disabled_alert"
+= dispensable_render_if_exists "projects/code_suggestions_alert", project: @project
+= dispensable_render_if_exists "projects/code_suggestions_third_party_alert", project: @project
= dispensable_render_if_exists "projects/free_user_cap_alert", project: @project
= dispensable_render_if_exists 'shared/unlimited_members_during_trial_alert', resource: @project
diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml
index 71c622d7a62..9a50e3e2eb2 100644
--- a/app/views/layouts/terms.html.haml
+++ b/app/views/layouts/terms.html.haml
@@ -17,9 +17,9 @@
%div{ class: "#{container_class} limit-container-width" }
.content{ id: "content-body" }
= render Pajamas::CardComponent.new do |c|
- = c.header do
+ - c.with_header do
= brand_header_logo({add_gitlab_black_text: true})
- = c.body do
+ - c.with_body do
- if header_link?(:user_dropdown)
.navbar-collapse
%ul.nav.navbar-nav
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
index f6b517d6e34..9c25567696f 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.html.haml
@@ -78,7 +78,7 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" }
%img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" }
- %span= _('Merge request was scheduled to merge after pipeline succeeds')
+ %span= _('Merge request was set to auto-merge')
%tr.spacer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }
&nbsp;
@@ -91,7 +91,7 @@
%img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" }
%span{ style: "font-weight: 600;color:#333333;" }= _('Merge request')
%a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference
- %span= _('was scheduled to merge after pipeline succeeds by')
+ %span= _('was set to auto-merge by')
%img.avatar{ height: "24", src: avatar_icon_for_user(@mwps_set_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }
%a.muted{ href: user_url(@mwps_set_by), style: "color:#333333;text-decoration:none;" }
= @mwps_set_by.name
diff --git a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
index dbf742a5cbc..cbaa88befd2 100644
--- a/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
+++ b/app/views/notify/merge_when_pipeline_succeeds_email.text.haml
@@ -1,4 +1,4 @@
-Merge request #{@merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{sanitize_name(@mwps_set_by.name)}
+Merge request #{@merge_request.to_reference} was set to auto-merge by #{sanitize_name(@mwps_set_by.name)}
Merge request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
diff --git a/app/views/organizations/organizations/directory.html.haml b/app/views/organizations/organizations/directory.html.haml
new file mode 100644
index 00000000000..1d2fb66112b
--- /dev/null
+++ b/app/views/organizations/organizations/directory.html.haml
@@ -0,0 +1,2 @@
+- breadcrumb_title @organization.name
+- page_title @organization.name
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 0505a205333..fec5d2d5ff5 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -4,14 +4,14 @@
- if current_user.ldap_user?
= render Pajamas::AlertComponent.new(alert_options: { class: 'gl-my-5' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= s_('Profiles|Some options are unavailable for LDAP accounts')
- if params[:two_factor_auth_enabled_successfully]
= render Pajamas::AlertComponent.new(variant: :success,
alert_options: { class: 'gl-my-5' },
close_button_options: { class: 'js-close-2fa-enabled-success-alert' }) do |c|
- = c.body do
+ - c.with_body do
= 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 }
.row.gl-mt-3.js-search-settings-section
@@ -52,43 +52,52 @@
%p
= s_('Profiles|Changing your username can have unintended side effects.')
= succeed '.' do
- = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more'), help_page_path('user/profile/index', anchor: 'change-your-username'), target: '_blank', rel: 'noopener noreferrer'
.col-lg-8
- data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
#update-username{ data: data }
.col-lg-12
%hr
-.row.gl-mt-3.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0.danger-title
- = s_('Profiles|Delete account')
- .col-lg-8
- - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
+- if prevent_delete_account?
+ .row.gl-mt-3.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0.danger-title
+ = s_('Profiles|Delete account')
+ .col-lg-8
%p
- = s_('Profiles|Deleting an account has the following effects:')
- = render 'users/deletion_guidance', user: current_user
-
- -# Delete button here
- = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do
+ = s_('Profiles|Account deletion is not allowed by your administrator.')
+- else
+ .row.gl-mt-3.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0.danger-title
= s_('Profiles|Delete account')
-
- #delete-account-modal{ data: { action_url: user_registration_path,
- confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
- username: current_user.username } }
- - else
- - if current_user.solo_owned_groups.present?
- %p
- = s_('Profiles|Your account is currently an owner in these groups:')
- %strong= current_user.solo_owned_groups.map(&:name).join(', ')
- %p
- = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
- - elsif !current_user.can_remove_self?
- %p
- = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe}
+ .col-lg-8
+ - if current_user.can_be_removed? && can?(current_user, :destroy_user, current_user)
%p
- = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe}
+ = s_('Profiles|Deleting an account has the following effects:')
+ = render 'users/deletion_guidance', user: current_user
+
+ -# Delete button here
+ = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { id: 'delete-account-button', disabled: true, data: { qa_selector: 'delete_account_button' }}) do
+ = s_('Profiles|Delete account')
+
+ #delete-account-modal{ data: { action_url: user_registration_path,
+ confirm_with_password: ('true' if current_user.confirm_deletion_with_password?),
+ username: current_user.username } }
- else
- %p
- = s_("Profiles|You don't have access to delete this user.")
+ - if current_user.solo_owned_groups.present?
+ %p
+ = s_('Profiles|Your account is currently an owner in these groups:')
+ %strong= current_user.solo_owned_groups.map(&:name).join(', ')
+ %p
+ = s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
+ - elsif !current_user.can_remove_self?
+ %p
+ = s_('Profiles|GitLab is unable to verify your identity automatically. For security purposes, you must set a password by %{openingTag}resetting your password%{closingTag} to delete your account.').html_safe % { openingTag: "<a href='#{reset_profile_password_path}' rel=\"nofollow\" data-method=\"put\">".html_safe, closingTag: '</a>'.html_safe}
+ %p
+ = s_('Profiles|If after setting a password, the option to delete your account is still not available, please %{link_start}submit a request%{link_end} to begin the account deletion process.').html_safe % { link_start: '<a href="https://support.gitlab.io/account-deletion/" rel="nofollow noreferrer noopener" target="_blank">'.html_safe, link_end: '</a>'.html_safe}
+ - else
+ %p
+ = s_("Profiles|You don't have access to delete this user.")
.gl-mb-3
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index 54736153223..1952655937e 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -11,6 +11,6 @@
.gl-mb-3
= render Pajamas::CardComponent.new(card_options: { class: 'gl-border-0' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.body do
+ - c.with_body do
%ul.list-group.list-group-flush
= render partial: 'profiles/active_sessions/active_session', collection: @sessions
diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml
index bc30ccc5821..b0a694f9bc6 100644
--- a/app/views/profiles/chat_names/new.html.haml
+++ b/app/views/profiles/chat_names/new.html.haml
@@ -3,9 +3,9 @@
%main{ role: 'main' }
.gl-max-w-80.gl-mx-auto.gl-mt-6
= render Pajamas::CardComponent.new do |c|
- - c.header do
+ - c.with_header do
%h4.gl-m-0= sprintf(s_('Integrations|Authorize %{integration_name} (%{user}) to use your account?'), { user: @chat_name_params[:chat_name], integration_name: @integration_name })
- - c.body do
+ - c.with_body do
%p
= sprintf(s_('Integrations|An application called %{integration_name} is requesting access to your GitLab account. This application was created by GitLab Inc.'), { integration_name: @integration_name })
%p
@@ -16,7 +16,7 @@
%li= s_('SlackIntegration|Run ChatOps jobs.')
%p.gl-mb-0
= s_("SlackIntegration|You don't have to reauthorize this application if the permission scope changes in future releases.")
- - c.footer do
+ - c.with_footer do
.gl-display-flex
= form_tag profile_chat_names_path, method: :post do
= hidden_field_tag :token, @chat_name_token.token
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 3c05502be57..4f3d97fb90c 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -2,9 +2,9 @@
.row.gl-mt-3
.col-md-4
= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.header do
+ - c.with_header do
= _('SSH Key')
- - c.body do
+ - c.with_body do
%ul.content-list
%li
%span.light= _('Title:')
@@ -27,9 +27,9 @@
%pre.well-pre
= @key.key
= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0'}) do |c|
- - c.header do
+ - c.with_header do
= _('Fingerprints')
- - c.body do
+ - c.with_body do
%ul.content-list
%li
%span.light= 'MD5:'
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index a632c450eda..06d37787d2e 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -5,7 +5,7 @@
%div
- if @user.errors.any?
= render Pajamas::AlertComponent.new(variant: :danger) do |c|
- = c.body do
+ - c.with_body do
%ul
- @user.errors.full_messages.each do |msg|
%li= msg
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 7f8858411ca..a085840ee84 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -90,6 +90,8 @@
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a project’s overview page.')
.form-group
+ = f.gitlab_ui_checkbox_component :project_shortcut_buttons, s_('Preferences|Show shortcut buttons above files on project overview')
+ .form-group
= f.gitlab_ui_checkbox_component :render_whitespace_in_code, s_('Preferences|Render whitespace characters in the Web IDE')
.form-group
= f.gitlab_ui_checkbox_component :show_whitespace_in_diffs, s_('Preferences|Show whitespace changes in diffs')
@@ -168,6 +170,7 @@
.form-group
= f.gitlab_ui_checkbox_component :enabled_following,
s_('Preferences|Enable follow users')
-
+ = render_if_exists 'profiles/preferences/code_suggestions_settings', form: f
+ = render_if_exists 'profiles/preferences/zoekt_settings', form: f
#js-profile-preferences-app{ data: data_attributes }
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 930f4f5c397..1a932ed7b35 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -4,195 +4,198 @@
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
- @force_desktop_expanded_sidebar = true
-= gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_("Profiles|Public avatar")
- %p
- - if @user.avatar?
- - if gravatar_enabled?
- = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
- - else
- = s_("Profiles|You can change your avatar here")
- - else
- - if gravatar_enabled?
- = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
+- if Feature.enabled?(:edit_user_profile_vue, current_user)
+ .js-user-profile
+- else
+ = gitlab_ui_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user js-edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f|
+ .row.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = s_("Profiles|Public avatar")
+ %p
+ - if @user.avatar?
+ - if gravatar_enabled?
+ = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
+ - else
+ = s_("Profiles|You can change your avatar here")
- else
- = s_("Profiles|You can upload your avatar here")
- - if current_appearance&.profile_image_guidelines?
- .md
- = brand_profile_image_guidelines
- .col-lg-8
- .avatar-image
- = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
- = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
- %h5.gl-mt-0= s_("Profiles|Upload new avatar")
- .gl-display-flex.gl-align-items-center.gl-my-3
- = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
- = s_("Profiles|Choose file...")
- %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
- = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
- .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
- - if @user.avatar?
- = render Pajamas::ButtonComponent.new(variant: :danger,
- category: :secondary,
- href: profile_avatar_path,
- button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
- method: :delete) do
- = s_("Profiles|Remove avatar")
- .col-lg-12
- %hr
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0= s_("Profiles|Current status")
- %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
- .col-lg-8
- #js-user-profile-set-status-form
- = f.fields_for :status, @user.status do |status_form|
- = status_form.hidden_field :emoji, data: { js_name: 'emoji' }
- = status_form.hidden_field :message, data: { js_name: 'message' }
- = status_form.hidden_field :availability, data: { js_name: 'availability' }
- = status_form.hidden_field :clear_status_after,
- value: user_clear_status_at(@user),
- data: { js_name: 'clearStatusAfter' }
- .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")
- %p= s_("Profiles|Set your local time zone.")
- .col-lg-8
- = f.label :user_timezone, _("Time zone")
- .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
- .col-lg-12
- %hr
- .row.js-search-settings-section
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_("Profiles|Main settings")
- %p
- = s_("Profiles|This information will appear on your profile.")
- - if current_user.ldap_user?
- = s_("Profiles|Some options are unavailable for LDAP accounts")
- .col-lg-8
- .row
- .form-group.gl-form-group.col-md-9.rspec-full-name
- = render 'profiles/name', form: f, user: @user
- .form-group.gl-form-group.col-md-3
- = f.label :id, s_('Profiles|User ID')
- = f.text_field :id, class: 'gl-form-input form-control', readonly: true
- .form-group.gl-form-group
- = f.label :pronouns, s_('Profiles|Pronouns')
- = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Enter your pronouns to let people know how to refer to you.")
- .form-group.gl-form-group
- = f.label :pronunciation, s_('Profiles|Pronunciation')
- = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
- = render_if_exists 'profiles/extra_settings', form: f
- = render_if_exists 'profiles/email_settings', form: f
- .form-group.gl-form-group
- = f.label :skype
- = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
- .form-group.gl-form-group
- = f.label :linkedin
- = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
- .form-group.gl-form-group
- = f.label :twitter
- = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
- .form-group.gl-form-group
- - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page')
- - external_accounts_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: external_accounts_help_url }
- - external_accounts_docs_link = s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}').html_safe % { external_accounts_link_start: external_accounts_link_start, external_accounts_link_end: '</a>'.html_safe }
- - min_discord_length = 17
- - max_discord_length = 20
- = f.label :discord
- = f.text_field :discord,
- class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
- placeholder: s_("Profiles|User ID"),
- data: { min_length: min_discord_length,
- min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
- max_length: max_discord_length,
- max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
- allow_empty: true}
- %small.form-text.text-gl-muted
- = external_accounts_docs_link
+ - if gravatar_enabled?
+ = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link }
+ - else
+ = s_("Profiles|You can upload your avatar here")
+ - if current_appearance&.profile_image_guidelines?
+ .md
+ = brand_profile_image_guidelines
+ .col-lg-8
+ .avatar-image
+ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
+ = render Pajamas::AvatarComponent.new(@user, size: 96, alt: "", class: 'gl-float-left gl-mr-5')
+ %h5.gl-mt-0= s_("Profiles|Upload new avatar")
+ .gl-display-flex.gl-align-items-center.gl-my-3
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-choose-user-avatar-button' }) do
+ = s_("Profiles|Choose file...")
+ %span.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen.")
+ = f.file_field :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
+ .gl-text-gray-500= s_("Profiles|The maximum file size allowed is 200KB.")
+ - if @user.avatar?
+ = render Pajamas::ButtonComponent.new(variant: :danger,
+ category: :secondary,
+ href: profile_avatar_path,
+ button_options: { class: 'gl-mt-3', data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") } },
+ method: :delete) do
+ = s_("Profiles|Remove avatar")
+ .col-lg-12
+ %hr
+ .row.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0= s_("Profiles|Current status")
+ %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.")
+ .col-lg-8
+ #js-user-profile-set-status-form
+ = f.fields_for :status, @user.status do |status_form|
+ = status_form.hidden_field :emoji, data: { js_name: 'emoji' }
+ = status_form.hidden_field :message, data: { js_name: 'message' }
+ = status_form.hidden_field :availability, data: { js_name: 'availability' }
+ = status_form.hidden_field :clear_status_after,
+ value: user_clear_status_at(@user),
+ data: { js_name: 'clearStatusAfter' }
+ .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")
+ %p= s_("Profiles|Set your local time zone.")
+ .col-lg-8
+ = f.label :user_timezone, _("Time zone")
+ .js-timezone-dropdown{ data: { timezone_data: timezone_data.to_json, initial_value: @user.timezone, name: 'user[timezone]' } }
+ .col-lg-12
+ %hr
+ .row.js-search-settings-section
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = s_("Profiles|Main settings")
+ %p
+ = s_("Profiles|This information will appear on your profile.")
+ - if current_user.ldap_user?
+ = s_("Profiles|Some options are unavailable for LDAP accounts")
+ .col-lg-8
+ .row
+ .form-group.gl-form-group.col-md-9.rspec-full-name
+ = render 'profiles/name', form: f, user: @user
+ .form-group.gl-form-group.col-md-3
+ = f.label :id, s_('Profiles|User ID')
+ = f.text_field :id, class: 'gl-form-input form-control', readonly: true
+ .form-group.gl-form-group
+ = f.label :pronouns, s_('Profiles|Pronouns')
+ = f.text_field :pronouns, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Enter your pronouns to let people know how to refer to you.")
+ .form-group.gl-form-group
+ = f.label :pronunciation, s_('Profiles|Pronunciation')
+ = f.text_field :pronunciation, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Enter how your name is pronounced to help people address you correctly.")
+ = render_if_exists 'profiles/extra_settings', form: f
+ = render_if_exists 'profiles/email_settings', form: f
+ .form-group.gl-form-group
+ = f.label :skype
+ = f.text_field :skype, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|username")
+ .form-group.gl-form-group
+ = f.label :linkedin
+ = f.text_field :linkedin, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
+ .form-group.gl-form-group
+ = f.label :twitter
+ = f.text_field :twitter, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|@username")
+ .form-group.gl-form-group
+ - external_accounts_help_url = help_page_path('user/profile/index', anchor: 'add-external-accounts-to-your-user-profile-page')
+ - external_accounts_link = link_to '', external_accounts_help_url, target: "_blank", rel: "noopener noreferrer"
+ - external_accounts_docs_link = safe_format(s_('Profiles|Your Discord user ID. %{external_accounts_link_start}Learn more.%{external_accounts_link_end}'), tag_pair(external_accounts_link, :external_accounts_link_start, :external_accounts_link_end))
+ - min_discord_length = 17
+ - max_discord_length = 20
+ = f.label :discord
+ = f.text_field :discord,
+ class: 'gl-form-input form-control gl-md-form-input-lg js-validate-length',
+ placeholder: s_("Profiles|User ID"),
+ data: { min_length: min_discord_length,
+ min_length_message: s_('Profiles|Discord ID is too short (minimum is %{min_length} characters).') % { min_length: min_discord_length },
+ max_length: max_discord_length,
+ max_length_message: s_('Profiles|Discord ID is too long (maximum is %{max_length} characters).') % { max_length: max_discord_length },
+ allow_empty: true}
+ %small.form-text.text-gl-muted
+ = external_accounts_docs_link
- .form-group.gl-form-group
- = f.label :website_url, s_('Profiles|Website url')
- = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
- .form-group.gl-form-group
- = f.label :location, s_('Profiles|Location')
- - if @user.read_only_attribute?(:location)
- = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
+ .form-group.gl-form-group
+ = f.label :website_url, s_('Profiles|Website url')
+ = f.text_field :website_url, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|https://website.com")
+ .form-group.gl-form-group
+ = f.label :location, s_('Profiles|Location')
+ - if @user.read_only_attribute?(:location)
+ = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', readonly: true
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
+ - else
+ = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
+ .form-group.gl-form-group
+ = f.label :job_title, s_('Profiles|Job title')
+ = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
+ .form-group.gl-form-group
+ = f.label :organization, s_('Profiles|Organization')
+ = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
+ %small.form-text.text-gl-muted
+ = s_("Profiles|Who you represent or work for.")
+ .form-group.gl-form-group
+ = f.label :bio, s_('Profiles|Bio')
+ = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
%small.form-text.text-gl-muted
- = s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- - else
- = f.text_field :location, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: s_("Profiles|City, country")
- .form-group.gl-form-group
- = f.label :job_title, s_('Profiles|Job title')
- = f.text_field :job_title, class: 'gl-form-input form-control gl-md-form-input-lg'
- .form-group.gl-form-group
- = f.label :organization, s_('Profiles|Organization')
- = f.text_field :organization, class: 'gl-form-input form-control gl-md-form-input-lg'
- %small.form-text.text-gl-muted
- = s_("Profiles|Who you represent or work for.")
- .form-group.gl-form-group
- = f.label :bio, s_('Profiles|Bio')
- = f.text_area :bio, class: 'gl-form-input gl-form-textarea form-control', rows: 4, maxlength: 250
- %small.form-text.text-gl-muted
- = s_("Profiles|Tell us about yourself in fewer than 250 characters.")
- %hr
- %fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
- = _('Private profile')
- - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.")
- - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
- = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe }
- %fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
- = s_("Profiles|Private contributions")
- = f.gitlab_ui_checkbox_component :include_private_contributions,
- s_('Profiles|Include private contributions on your profile'),
- help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
- %fieldset.form-group.gl-form-group
- %legend.col-form-label.col-form-label
- = s_("Profiles|Achievements")
- = f.gitlab_ui_checkbox_component :achievements_enabled,
- s_('Profiles|Display achievements on your profile')
- .row.js-hide-when-nothing-matches-search
- .col-lg-12
- %hr
- = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true
- = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do
- = s_('TagsPage|Cancel')
+ = s_("Profiles|Tell us about yourself in fewer than 250 characters.")
+ %hr
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = _('Private profile')
+ - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.")
+ - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private')
+ = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe }
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_("Profiles|Private contributions")
+ = f.gitlab_ui_checkbox_component :include_private_contributions,
+ s_('Profiles|Include private contributions on your profile'),
+ help_text: s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
+ %fieldset.form-group.gl-form-group
+ %legend.col-form-label.col-form-label
+ = s_("Profiles|Achievements")
+ = f.gitlab_ui_checkbox_component :achievements_enabled,
+ s_('Profiles|Display achievements on your profile')
+ .row.js-hide-when-nothing-matches-search
+ .col-lg-12
+ %hr
+ = f.submit s_("Profiles|Update profile settings"), class: 'gl-mr-3 js-password-prompt-btn', pajamas_button: true
+ = render Pajamas::ButtonComponent.new(href: user_path(current_user)) do
+ = s_('TagsPage|Cancel')
-#password-prompt-modal
+ #password-prompt-modal
-.modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
- .modal-dialog
- .modal-content
- .modal-header
- %h4.modal-title
- = s_("Profiles|Position and size your new avatar")
- = render Pajamas::ButtonComponent.new(category: :tertiary,
- icon: 'close',
- button_options: { class: 'close', "data-dismiss": "modal", "aria-label" => _("Close") })
- .modal-body
- .profile-crop-image-container
- %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
- .gl-text-center.gl-mt-4
- .btn-group
- = render Pajamas::ButtonComponent.new(icon: 'search-minus',
- button_options: {data: { method: 'zoom', option: '-0.1' }})
- = render Pajamas::ButtonComponent.new(icon: 'search-plus',
- button_options: {data: { method: 'zoom', option: '0.1' }})
- .modal-footer
- = render Pajamas::ButtonComponent.new(variant: :confirm,
- button_options: { class: 'js-upload-user-avatar'}) do
- = s_("Profiles|Set new profile picture")
+ .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h4.modal-title
+ = s_("Profiles|Position and size your new avatar")
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
+ icon: 'close',
+ button_options: { class: 'close', "data-dismiss": "modal", "aria-label" => _("Close") })
+ .modal-body
+ .profile-crop-image-container
+ %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") }
+ .gl-text-center.gl-mt-4
+ .btn-group
+ = render Pajamas::ButtonComponent.new(icon: 'search-minus',
+ button_options: {data: { method: 'zoom', option: '-0.1' }})
+ = render Pajamas::ButtonComponent.new(icon: 'search-plus',
+ button_options: {data: { method: 'zoom', option: '0.1' }})
+ .modal-footer
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ button_options: { class: 'js-upload-user-avatar'}) do
+ = s_("Profiles|Set new profile picture")
diff --git a/app/views/profiles/slacks/edit.html.haml b/app/views/profiles/slacks/edit.html.haml
new file mode 100644
index 00000000000..20274735650
--- /dev/null
+++ b/app/views/profiles/slacks/edit.html.haml
@@ -0,0 +1,6 @@
+- add_to_breadcrumbs _('Profile'), profile_path
+- @hide_top_links = true
+- @content_class = 'limit-container-width'
+- page_title s_('SlackIntegration|GitLab for Slack')
+
+.js-gitlab-slack-application{ data: gitlab_slack_application_data(@projects) }
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 9cc7f6bdd49..461164e1ae9 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -24,7 +24,7 @@
= raw @qr_code
.col-md-8
= render Pajamas::CardComponent.new do |c|
- - c.body do
+ - c.with_body do
%p.gl-mt-0.gl-mb-3.gl-font-weight-bold
= _("Can't scan the code?")
%p.gl-mt-0.gl-mb-3
@@ -42,7 +42,7 @@
variant: :danger,
alert_options: { class: 'gl-mb-3' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
- if current_password_required?
@@ -130,7 +130,7 @@
variant: :danger,
alert_options: { class: 'gl-mb-3' },
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
.js-manage-two-factor-form{ data: { current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } }
- else
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 5c7f83fc579..b5bbb57d58f 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -2,6 +2,7 @@
- is_project_overview = local_assigns.fetch(:is_project_overview, false)
- ref = local_assigns.fetch(:ref) { current_ref }
- project = local_assigns.fetch(:project) { @project }
+- has_project_shortcut_buttons = !current_user || current_user.project_shortcut_buttons
- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0)
- if readme_path = @project.repository.readme_path
- add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json")
@@ -10,7 +11,8 @@
.info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column.gl-mt-5
#js-last-commit.gl-m-auto
= gl_loading_icon(size: 'md')
- #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
+ - if project.licensed_feature_available?(:code_owners)
+ #js-code-owners{ data: { branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
= render 'projects/tree/tree_header', tree: @tree, is_project_overview: is_project_overview
@@ -18,7 +20,7 @@
- if project.forked?
#js-fork-info{ data: vue_fork_divergence_data(project, ref) }
- - if is_project_overview
+ - if is_project_overview && has_project_shortcut_buttons
.project-buttons.gl-mb-5.js-show-on-project-root{ data: { qa_selector: 'project_buttons' } }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index f205fe2b9bf..dd32d3f9d92 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -14,6 +14,10 @@
- ffTrains = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.')
- ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer'
+- ffTrainsWithFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe
+- ffTrainsWithoutFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase).html_safe
+- ffTrainsHelpFullHelpText = Feature.enabled?(:fast_forward_merge_trains_support) ? ffTrainsWithFastForward : ffTrainsWithoutFastForward
+
.form-group
%b= s_('ProjectSettings|Merge method')
%p.text-secondary
@@ -30,5 +34,5 @@
= form.gitlab_ui_radio_component :merge_method,
:ff,
labelFastForward,
- help_text: (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe,
+ help_text: ffTrainsHelpFullHelpText,
radio_options: { data: { qa_selector: 'merge_ff_radio' } }
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 70a2476c8e5..6049d1cc110 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -48,7 +48,7 @@
variant: :success) do |c|
- c.with_body do
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/profile/index', anchor: 'add-details-to-your-profile-with-a-readme') }
- = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more.%{help_link_end}')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
+ = html_escape(_('%{project_path} is a project that you can use to add a README to your GitLab profile. Create a public project and initialize the repository with a README to get started. %{help_link_start}Learn more%{help_link_end}.')) % { project_path: "<strong>#{current_user.username} / #{current_user.username}</strong>".html_safe, help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- if include_description
.form-group
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 7654677d8a8..14991ce3824 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -10,6 +10,7 @@
- if ::Gitlab::ServiceDesk.supported?
.js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project),
enabled: "#{@project.service_desk_enabled}",
+ issue_tracker_enabled: "#{@project.project_feature.issues_enabled?}",
incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled),
custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}",
diff --git a/app/views/projects/blame/_page.html.haml b/app/views/projects/blame/_page.html.haml
index 92fb99c30a6..10f27c3f620 100644
--- a/app/views/projects/blame/_page.html.haml
+++ b/app/views/projects/blame/_page.html.haml
@@ -7,7 +7,7 @@
- line_count = blame_group[:lines].count
.tr{ class: ('last-row' if groups_length == index) }
- .td.blame-commit.commit{ class: commit_data.age_map_class }
+ .td.blame-commit.commit.gl-py-3.gl-px-4{ class: commit_data.age_map_class }
= commit_data.author_avatar
.commit-row-title
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 453a60a62f4..e5566882371 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -2,7 +2,8 @@
- project = @project.present(current_user: current_user)
- ref = local_assigns[:ref] || @ref
- expanded = params[:expanded].present?
-- if blob.rich_viewer
+-# If the blob has a RichViewer we preload the content except for GeoJSON since it is handled by Vue
+- if blob.rich_viewer && blob.extension != 'geojson'
- add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) }
.info-well.d-none.d-sm-block
@@ -10,7 +11,8 @@
%ul.blob-commit-info
= render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref
- #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
+ - if project.licensed_feature_available?(:code_owners)
+ #js-code-owners{ data: { blob_path: blob.path, project_path: @project.full_path, branch: @ref, can_view_branch_rules: can_view_branch_rules?, branch_rules_path: branch_rules_path } }
= render "projects/blob/auxiliary_viewer", blob: blob
- if project.forked?
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 621cd251bdf..68520d36858 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -26,7 +26,10 @@
dismiss_key: @project.id,
human_access: human_access } }
- - unless Feature.enabled?(:source_editor_toolbar, current_user)
+ - if Feature.enabled?(:source_editor_toolbar, current_user)
+ #editor-toolbar
+
+ - else
.file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end
- if is_markdown
.md-header.gl-display-flex.gl-px-2.gl-rounded-base.gl-mx-2.gl-mt-2
@@ -40,8 +43,6 @@
= _("Soft wrap")
.file-editor.code
- - if Feature.enabled?(:source_editor_toolbar, current_user)
- #editor-toolbar
.js-edit-mode-pane#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }<
%pre.editor-loading-content= params[:content] || local_assigns[:blob_data]
- if local_assigns[:path]
diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml
index 1ff68cd2d11..d5dd24a6995 100644
--- a/app/views/projects/blob/_render_error.html.haml
+++ b/app/views/projects/blob/_render_error.html.haml
@@ -3,5 +3,5 @@
The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}.
You can
- = Gitlab::Utils.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe
+ = Gitlab::Sentence.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe
instead.
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index c1f4633f69f..0bd29ceb563 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -4,8 +4,6 @@
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } })
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } })
- .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } })
#gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } })
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml
index eac8c17b7ff..efc12a31603 100644
--- a/app/views/projects/blob/viewers/_contributing.html.haml
+++ b/app/views/projects/blob/viewers/_contributing.html.haml
@@ -4,6 +4,6 @@
- options = contribution_options(viewer.project)
- if options.any?
= succeed '.' do
- = Gitlab::Utils.to_exclusive_sentence(options).html_safe
+ = Gitlab::Sentence.to_exclusive_sentence(options).html_safe
- else
= _("contribute to this project.")
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index dbc1fe24d96..adff64fad5a 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,51 +1,62 @@
- merged = local_assigns.fetch(:merged, false)
- commit = @repository.commit(branch.dereferenced_target)
-- merge_project = merge_request_source_project_for_project(@project)
-%li{ class: "branch-item gl-py-3! js-branch-item js-branch-#{branch.name}", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
- .branch-item-content.gl-display-flex.gl-align-items-center.gl-px-3.gl-py-2
- .branch-info
- .gl-display-flex.gl-align-items-center
- = sprite_icon('branch', size: 12, css_class: 'gl-flex-shrink-0')
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3', data: { qa_selector: 'branch_link' } do
- = branch.name
- = clipboard_button(text: branch.name, title: _("Copy branch name"))
- - if branch.name == @repository.root_ref
- = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :info, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
- - elsif merged
- = gl_badge_tag s_('Branches|merged'), { variant: :info, size: :sm }, { class: 'gl-ml-2', title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref }, data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } }
- - if protected_branch?(@project, branch)
- = gl_badge_tag s_('Branches|protected'), { variant: :success, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
-
- = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
-
- .block-truncated
- - if commit
- = render 'projects/branches/commit', commit: commit, project: @project
- - else
- = s_('Branches|Can’t find HEAD commit for this branch')
-
- - if branch.name != @repository.root_ref
- .js-branch-divergence-graph
-
- .controls.d-none.d-md-block<
- - if commit_status
- = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
- - elsif show_commit_status
- .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
- %svg.s24
-
- - if merge_project && create_mr_button?(from: branch.name, source_project: @project)
- = render Pajamas::ButtonComponent.new(href: create_mr_path(from: branch.name, source_project: @project)) do
- = _('Merge request')
-
- - if branch.name != @repository.root_ref
- = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
- class: "gl-button btn btn-default js-onboarding-compare-branches #{'gl-ml-3' unless merge_project}",
- method: :post,
- title: s_('Branches|Compare') do
- = s_('Branches|Compare')
-
- = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], class: 'gl-vertical-align-top'
-
- - if can?(current_user, :push_code, @project)
- = render 'projects/branches/delete_branch_modal_button', project: @project, branch: branch, merged: merged
+- related_merge_request = @related_merge_requests[branch.name]&.first
+- mr_status = merge_request_status(related_merge_request)
+- is_default_branch = branch.name == @repository.root_ref
+
+%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-3!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } }
+ .branch-info
+ .gl-display-flex.gl-align-items-center
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do
+ = branch.name
+ = clipboard_button(text: branch.name, title: _("Copy branch name"))
+ - if is_default_branch
+ = gl_badge_tag s_('DefaultBranchLabel|default'), { variant: :neutral, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+ - if protected_branch?(@project, branch)
+ = gl_badge_tag s_('Branches|protected'), { variant: :muted, size: :sm }, { class: 'gl-ml-2', data: { qa_selector: 'badge_content' } }
+
+ = render_if_exists 'projects/branches/diverged_from_upstream', branch: branch
+
+ .block-truncated
+ - if commit
+ = render 'projects/branches/commit', commit: commit, project: @project
+ - else
+ = s_('Branches|Can’t find HEAD commit for this branch')
+
+ - if branch.name != @repository.root_ref
+ .js-branch-divergence-graph
+
+ .pipeline-status.d-none.d-md-block<
+ - if commit_status
+ = render 'ci/status/icon', size: 16, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5'
+ - elsif show_commit_status
+ .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5
+ %svg.s16
+
+
+ - if mr_status.present?
+ .issuable-reference.gl-display-flex.gl-justify-content-end.gl-min-w-10.gl-ml-5.gl-mr-4
+ = gl_badge_tag issuable_reference(related_merge_request),
+ { icon: mr_status[:icon], variant: mr_status[:variant], size: :md, href: merge_request_path(related_merge_request) },
+ { class: 'gl-mr-2', title: mr_status[:title], data: { toggle: 'tooltip', container: 'body', qa_selector: 'badge_content' } }
+
+ .controls.d-none.d-md-block<
+ - if mr_status.nil? && create_mr_button?(from: branch.name, source_project: @project)
+ = render Pajamas::ButtonComponent.new(icon: 'merge-request', href: create_mr_path(from: branch.name, source_project: @project), button_options: { class: 'has-tooltip gl-mr-2!', title: _('New merge request') }) do
+ = _('New')
+
+ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name], css_class: 'gl-mr-1!'
+
+ - if !is_default_branch
+ .js-branch-more-actions{ data: {
+ branch_name: branch.name,
+ default_branch_name: @repository.root_ref,
+ can_delete_branch: user_access(@project).can_delete_branch?(branch.name).to_s,
+ is_protected_branch: protected_branch?(@project, branch).to_s,
+ merged: merged.to_s,
+ compare_path: project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
+ delete_path: project_branch_path(@project, branch.name),
+ } }
+ - else
+ .gl-display-inline-flex.gl-w-7
+ &nbsp;
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index cfa0cf6d07b..6bbd0617598 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,9 +1,7 @@
-.branch-commit.cgray
- .icon-container.commit-icon
- = custom_icon("icon_commit")
+.branch-commit.gl-font-sm.gl-text-gray-500
= link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha"
&middot;
%span.str-truncated
- = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray"
+ = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message gl-text-gray-500!"
&middot;
%span.gl-text-secondary= time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/branches/_delete_branch_modal_button.html.haml b/app/views/projects/branches/_delete_branch_modal_button.html.haml
deleted file mode 100644
index 829a459ad2c..00000000000
--- a/app/views/projects/branches/_delete_branch_modal_button.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-- if branch.name == @project.repository.root_ref
- .js-delete-branch-button{ data: { tooltip: s_('Branches|The default branch cannot be deleted'),
- disabled: true.to_s } }
-- elsif protected_branch?(@project, branch)
- - if can?(current_user, :push_to_delete_protected_branch, @project)
- .js-delete-branch-button{ data: { branch_name: branch.name,
- is_protected_branch: true.to_s,
- merged: merged.to_s,
- default_branch_name: @project.repository.root_ref,
- delete_path: project_branch_path(@project, branch.name) } }
- - else
- .js-delete-branch-button{ data: { is_protected_branch: true.to_s,
- disabled: true.to_s } }
-- else
- .js-delete-branch-button{ data: { branch_name: branch.name,
- merged: merged.to_s,
- default_branch_name: @project.repository.root_ref,
- delete_path: project_branch_path(@project, branch.name) } }
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 64adf97b1b5..8992753c676 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,7 +1,7 @@
- add_page_specific_style 'page_bundles/branches'
- page_title _('Branches')
- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
-- is_branch_rules_available = (can? current_user, :maintainer_access, @project) && Feature.enabled?(:branch_rules, @project)
+- can_access_branch_rules = can?(current_user, :maintainer_access, @project)
- can_push_code = (can? current_user, :push_code, @project)
-# Possible values for variables passed down from the projects/branches_controller.rb
@@ -24,7 +24,7 @@
sorted_by: @sort }
}
- - if is_branch_rules_available
+ - if can_access_branch_rules
= link_to project_settings_repository_path(@project, anchor: 'js-branch-rules'), class: 'gl-button btn btn-default' do
= s_('Branches|View branch rules')
@@ -38,7 +38,7 @@
= render_if_exists 'projects/commits/mirror_status'
-- if is_branch_rules_available
+- if can_access_branch_rules
= render 'branch_rules_info'
.js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json), default_branch: @project.default_branch } }
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 1fbc399c3ff..bbee7d66dcb 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -1,10 +1,11 @@
- project = local_assigns.fetch(:project)
- ref = local_assigns.fetch(:ref)
- pipeline = local_assigns.fetch(:pipeline) { project.latest_successful_pipeline_for(ref) }
+- css_class = local_assigns.fetch(:css_class, '')
- if !project.empty_repo? && can?(current_user, :download_code, project)
- archive_prefix = "#{project.path}-#{ref.tr('/', '-')}"
- .project-action-button.dropdown.gl-dropdown.inline>
+ .project-action-button.dropdown.gl-dropdown.inline{ class: css_class }>
%button.gl-button.btn.btn-default.dropdown-toggle.gl-dropdown-toggle.dropdown-icon-only.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }
= sprite_icon('download', css_class: 'gl-icon dropdown-icon')
%span.sr-only= _('Select Archive Format')
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 079e24c6389..c161e1c9d2a 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -29,15 +29,14 @@
%pre.commit-description<
= preserve(markdown_field(@commit, :description))
-.info-well.js-commit-box-info{ 'data-commit-path' => branches_project_commit_path(@project, @commit.id) }
+.info-well
.well-segment
.icon-container.commit-icon
= custom_icon("icon_commit")
%span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
= link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
- .commit-info.branches
- = gl_loading_icon(inline: true, css_class: 'gl-vertical-align-middle')
+ #js-commit-branches-and-tags{ data: { full_path: @project.full_path, commit_sha: @commit.short_id } }
.well-segment.merge-request-info
.icon-container
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index 9cca928e794..5b99a88f29e 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -29,5 +29,5 @@
= link_to(_('Learn about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3')
-%a.signature-badge.gl-display-inline-block{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
+%a.signature-badge.gl-display-inline-block.gl-ml-4{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= gl_badge_tag label, variant: variant
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 9cbabaee774..a0f47f375f7 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -8,12 +8,10 @@
- hidden = @hidden_commit_count
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits|
- %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b{ data: { day: day } }
- %span.day.font-weight-bold= l(day, format: '%d %b, %Y')
- %span -
- %span.commits-count= n_("%d commit", "%d commits", daily_commits.size) % daily_commits.size
+ %li.js-commit-header.gl-py-2.gl-border-b{ data: { day: day } }
+ %span.day.font-weight-bold= l(day, format: '%b %d, %Y')
- %li.commits-row{ data: { day: day } }
+ %li.commits-row.gl-mb-6{ data: { day: day } }
%ul.content-list.commit-list.flex-list
- if Feature.enabled?(:cached_commits, project)
= render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) }
@@ -21,13 +19,13 @@
= render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }
- if context_commits.present?
- %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b
+ %li.js-commit-header.gl-py-2.gl-border-b
%span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count
- if can_update_merge_request
= render Pajamas::ButtonComponent.new(button_options: { class: 'gl-ml-3 add-review-item-modal-trigger', data: { context_commits_empty: 'false' } }) do
= _('Add/remove')
- %li.commits-row
+ %li.commits-row.gl-mb-6
%ul.content-list.commit-list.flex-list
- if Feature.enabled?(:cached_commits, project)
= render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: ->(commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 64bd1bf32f0..5ec95c3095d 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -17,9 +17,10 @@
.file-actions.gl-display-none.gl-sm-display-flex
#js-diff-stats{ data: diff_file_stats_data(diff_file) }
- if diff_file.blob&.readable_text?
- %span.has-tooltip{ title: _("Toggle comments for this file") }
- = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected', disabled: @diff_notes_disabled do
- = sprite_icon('comment')
+ - unless @diff_notes_disabled
+ %span.has-tooltip{ title: _("Toggle comments for this file") }
+ = link_to '#', class: 'js-toggle-diff-comments btn gl-button btn-default btn-icon selected' do
+ = sprite_icon('comment')
\
- if editable_diff?(diff_file)
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 9b64afa8c60..08aeb3d4b07 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -4,40 +4,33 @@
%a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
%table.text-file.diff-wrap-lines.code.code-commit.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' }
- - if Feature.enabled?(:inline_haml_diff_line_rendering, @project)
- - diff_file.highlighted_diff_lines.each do |line|
- - line_code = diff_file.line_code(line)
+ - diff_file.highlighted_diff_lines.each do |line|
+ - line_code = diff_file.line_code(line)
- %tr.line_holder{ class: line.type, id: line_code }
- - case line.type
- - when 'match'
- = diff_match_line line.old_pos, line.new_pos, text: line.text
- - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
- = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content]
- - when 'old-nonewline', 'new-nonewline'
- %td.old_line.diff-line-num
- %td.new_line.diff-line-num
- %td.line_content.match= line.text
- - else
- %td.old_line.diff-line-num{ class: "#{line.type} js-avatar-container", data: { linenumber: line.old_pos } }
- = add_diff_note_button(line_code, diff_file.position(line), line.type)
- %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } }
+ %tr.line_holder{ class: line.type, id: line_code }
+ - case line.type
+ - when 'match'
+ = diff_match_line line.old_pos, line.new_pos, text: line.text
+ - when 'old-nomappinginraw', 'new-nomappinginraw', 'unchanged-nomappinginraw'
+ = diff_nomappinginraw_line line, %w[old_line diff-line-num], %w[new_line diff-line-num], %w[line_content]
+ - when 'old-nonewline', 'new-nonewline'
+ %td.old_line.diff-line-num
+ %td.new_line.diff-line-num
+ %td.line_content.match= line.text
+ - else
+ %td.old_line.diff-line-num{ class: "#{line.type} js-avatar-container", data: { linenumber: line.old_pos } }
+ = add_diff_note_button(line_code, diff_file.position(line), line.type)
+ %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "new", line.old_pos) } }
- %td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } }
- %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "old", line.new_pos) } }
+ %td.new_line.diff-line-num{ class: line.type, data: { linenumber: line.new_pos } }
+ %a{ href: "##{line_code}", data: { linenumber: diff_link_number(line.type, "old", line.new_pos) } }
- %td.line_content{ class: line.type }<
- = diff_line_content(line.rich_text)
+ %td.line_content{ class: line.type }<
+ = diff_line_content(line.rich_text)
- - if line.discussable? && @grouped_diff_discussions.present? && @grouped_diff_discussions[line_code]
- - line_discussions = @grouped_diff_discussions[line_code]
- = render "discussions/diff_discussion", discussions: line_discussions, expanded: line_discussions.any?(&:expanded?)
-
- - else
- = render partial: "projects/diffs/line",
- collection: diff_file.highlighted_diff_lines,
- as: :line,
- locals: { diff_file: diff_file, discussions: @grouped_diff_discussions }
+ - if line.discussable? && @grouped_diff_discussions.present? && @grouped_diff_discussions[line_code]
+ - line_discussions = @grouped_diff_discussions[line_code]
+ = render "discussions/diff_discussion", discussions: line_discussions, expanded: line_discussions.any?(&:expanded?)
- if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any?
- last_line = diff_file.highlighted_diff_lines.last
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 02a69f25985..a5224db1be9 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -91,7 +91,7 @@
.sub-section.rename-repository
%h4.warning-title= _('Change path')
= render 'projects/errors'
- = form_for @project do |f|
+ = gitlab_ui_form_for @project do |f|
.form-group
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'rename-a-repository') }
%p= _("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
@@ -106,8 +106,8 @@
.input-group-prepend
.input-group-text
#{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control h-auto', data: { qa_selector: 'project_path_field' }
- = f.submit _('Change path'), class: "gl-button btn btn-danger", data: { qa_selector: 'change_path_button' }
+ = f.text_field :path, class: 'form-control', data: { qa_selector: 'project_path_field' }
+ = f.submit _('Change path'), class: "btn-danger", data: { qa_selector: 'change_path_button' }, pajamas_button: true
= render 'transfer', project: @project
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index a51d1080d96..deb3c33f733 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -43,13 +43,13 @@
:preserve
git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
cd #{h @project.path}
- git switch -c #{h escaped_default_branch_name}
+ git switch --create #{h escaped_default_branch_name}
touch README.md
git add README.md
git commit -m "add README"
- if @project.can_current_user_push_to_default_branch?
%span><
- git push -u origin #{h escaped_default_branch_name }
+ git push --set-upstream origin #{h escaped_default_branch_name }
%h5= _('Push an existing folder')
%pre.bg-light
@@ -61,7 +61,7 @@
git commit -m "Initial commit"
- if @project.can_current_user_push_to_default_branch?
%span><
- git push -u origin #{h escaped_default_branch_name }
+ git push --set-upstream origin #{h escaped_default_branch_name }
%h5= _('Push an existing Git repository')
%pre.bg-light
@@ -71,5 +71,5 @@
git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- if @project.can_current_user_push_to_default_branch?
%span><
- git push -u origin --all
- git push -u origin --tags
+ git push --set-upstream origin --all
+ git push --set-upstream origin --tags
diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml
index 7a275b51c74..c7752a45c63 100644
--- a/app/views/projects/environments/edit.html.haml
+++ b/app/views/projects/environments/edit.html.haml
@@ -4,4 +4,5 @@
#js-edit-environment{ data: { project_environments_path: project_environments_path(@project),
update_environment_path: project_environment_path(@project, @environment),
protected_environment_settings_path: (project_settings_ci_cd_path(@project, anchor: 'js-protected-environments-settings') if @project.licensed_feature_available?(:protected_environments)),
- environment: environment_data(@environment)} }
+ project_path: @project.full_path,
+ environment: environment_data(@environment) } }
diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml
index 11c36b5ea6d..9e8484b88b9 100644
--- a/app/views/projects/environments/new.html.haml
+++ b/app/views/projects/environments/new.html.haml
@@ -3,4 +3,4 @@
- page_title s_("Environments|New Environment")
- add_page_specific_style 'page_bundles/environments'
-#js-new-environment{ data: { project_environments_path: project_environments_path(@project) } }
+#js-new-environment{ data: { project_environments_path: project_environments_path(@project), project_path: @project.full_path, } }
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 09b0b7a4d9b..6fd5802213a 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,7 +1,7 @@
= render 'shared/alerts/positioning_disabled' if @sort == 'relative_position'
%ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
- = render partial: "projects/issues/issue", collection: @issues
+ = render partial: 'projects/issues/service_desk/issue', collection: @issues
- if @issues.blank?
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
= render empty_state_path
diff --git a/app/views/projects/issues/_work_item_links.html.haml b/app/views/projects/issues/_work_item_links.html.haml
index 981021c97e6..bf23fdc761b 100644
--- a/app/views/projects/issues/_work_item_links.html.haml
+++ b/app/views/projects/issues/_work_item_links.html.haml
@@ -1,4 +1,5 @@
.js-work-item-links-root{ data: { issuable_id: @issue.id,
+ issuable_iid: @issue.iid,
full_path: @project.full_path,
wi: work_items_index_data(@project),
register_path: new_user_registration_path(redirect_to_referer: 'yes'),
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml
index fc6ef2ea153..04ea6103b83 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/service_desk/_issue.html.haml
@@ -23,7 +23,6 @@
#{_('created %{timeAgoString} by %{email} via %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), email: issue.present(current_user: current_user).service_desk_reply_to, user: link_to_member(@project, issue.author, avatar: false) }}
- else
#{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(issue.created_at, placement: 'bottom'), user: link_to_member(@project, issue.author, avatar: false) }}
- = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author
- if issue.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
&nbsp;
@@ -44,7 +43,7 @@
- presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
= link_to_label(label, small: true)
- = render "projects/issues/issue_estimate", issue: issue
+ = render 'projects/issues/service_desk/issue_estimate', issue: issue
.issuable-meta
%ul.controls
diff --git a/app/views/projects/issues/_issue_estimate.html.haml b/app/views/projects/issues/service_desk/_issue_estimate.html.haml
index c49bf626f4e..c49bf626f4e 100644
--- a/app/views/projects/issues/_issue_estimate.html.haml
+++ b/app/views/projects/issues/service_desk/_issue_estimate.html.haml
diff --git a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
index 1c9143c633d..855625368a9 100644
--- a/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
+++ b/app/views/projects/issues/service_desk/_service_desk_empty_state.html.haml
@@ -6,7 +6,7 @@
- if Gitlab::ServiceDesk.supported?
.empty-state
.svg-content
- = render partial: 'shared/empty_states/icons/service_desk_empty_state', formats: :svg
+ = render partial: 'projects/issues/service_desk/icons/service_desk_empty_state', formats: :svg
.text-content
%h4= title_text
@@ -25,7 +25,7 @@
- else
.empty-state
.svg-content
- = render partial: 'shared/empty_states/icons/service_desk_setup', formats: :svg
+ = render partial: 'projects/issues/service_desk/icons/service_desk_setup', formats: :svg
.text-content
- if can_edit_project_settings
%h4= s_('ServiceDesk|Service Desk is not supported')
diff --git a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
index 2ed5675c0ad..95837748c7f 100644
--- a/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
+++ b/app/views/projects/issues/service_desk/_service_desk_info_content.html.haml
@@ -6,7 +6,7 @@
.media.gl-border-b.gl-pb-3.gl-text-left
.svg-content
- = render partial: 'shared/empty_states/icons/service_desk_callout', formats: :svg
+ = render partial: 'projects/issues/service_desk/icons/service_desk_callout', formats: :svg
.gl-mt-3.gl-ml-3
%h5= title_text
diff --git a/app/views/shared/empty_states/icons/_service_desk_callout.svg b/app/views/projects/issues/service_desk/icons/_service_desk_callout.svg
index 2886388279e..2886388279e 100644
--- a/app/views/shared/empty_states/icons/_service_desk_callout.svg
+++ b/app/views/projects/issues/service_desk/icons/_service_desk_callout.svg
diff --git a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg b/app/views/projects/issues/service_desk/icons/_service_desk_empty_state.svg
index 04c4870be07..04c4870be07 100644
--- a/app/views/shared/empty_states/icons/_service_desk_empty_state.svg
+++ b/app/views/projects/issues/service_desk/icons/_service_desk_empty_state.svg
diff --git a/app/views/shared/empty_states/icons/_service_desk_setup.svg b/app/views/projects/issues/service_desk/icons/_service_desk_setup.svg
index bb791b58593..bb791b58593 100644
--- a/app/views/shared/empty_states/icons/_service_desk_setup.svg
+++ b/app/views/projects/issues/service_desk/icons/_service_desk_setup.svg
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index ce7006001c7..7a4ae409ee2 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -17,7 +17,7 @@
-# Only show it in the first page
- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
.prioritized-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
%h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24
= _('Prioritized labels')
.gl-font-sm.gl-font-weight-semibold.gl-text-gray-500
@@ -33,7 +33,7 @@
- if @labels.any?
.other-labels.gl-rounded-base.gl-border.gl-bg-gray-10.gl-mt-4
- .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-base.gl-border-b{ class: 'gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!' }
+ .gl-px-5.gl-py-4.gl-bg-white.gl-rounded-top-base.gl-border-b
%h3.card-title.h5.gl-m-0.gl-relative.gl-line-height-24{ class: ('hide' if hide) }= _('Other labels')
.js-other-labels.gl-px-3.gl-rounded-base.manage-labels-list
= render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
index 9bfa0e7a309..a3536ead240 100644
--- a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -1,53 +1,15 @@
-- display_issuable_type = issuable_display_type(@merge_request)
-
-.btn-group.gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full
- %span.js-sidebar-header-popover
- = button_tag type: 'button', id: "new-actions-header-dropdown", class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret gl-display-none! gl-md-display-inline-flex!", title: _('Merge request actions'), 'aria-label': _('Merge request actions'), data: { toggle: 'dropdown', testid: 'merge-request-actions' } do
- = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon"
- = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do
- %span.gl-dropdown-button-text= _('Merge request actions')
- = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon"
- .dropdown-menu.dropdown-menu-right
- .gl-dropdown-inner
- .gl-dropdown-contents
- %ul
- - if current_user && moved_mr_sidebar_enabled?
- %li.gl-dropdown-item.js-sidebar-subscriptions-widget-root
- %li.gl-dropdown-divider
- %hr.dropdown-divider
- - if can?(current_user, :update_merge_request, @merge_request)
- %li.gl-dropdown-item{ class: "gl-md-display-none!" }
- = link_to edit_project_merge_request_path(@project, @merge_request), class: 'dropdown-item' do
- .gl-dropdown-item-text-wrapper
- = _('Edit')
- - if @merge_request.open?
- %li.gl-dropdown-item
- = link_to toggle_draft_merge_request_path(@merge_request), method: :put, class: 'dropdown-item js-draft-toggle-button' do
- .gl-dropdown-item-text-wrapper
- = @merge_request.draft? ? _('Mark as ready') : _('Mark as draft')
- %li.gl-dropdown-item.js-close-item
- = link_to close_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do
- .gl-dropdown-item-text-wrapper
- = _('Close')
- = display_issuable_type
- - elsif !@merge_request.source_project_missing? && @merge_request.closed?
- %li.gl-dropdown-item
- = link_to reopen_issuable_path(@merge_request), method: :put, class: 'dropdown-item' do
- .gl-dropdown-item-text-wrapper
- = _('Reopen')
- = display_issuable_type
- - if moved_mr_sidebar_enabled?
- %li.gl-dropdown-item.js-sidebar-lock-root
- %li.gl-dropdown-item
- %button.dropdown-item.js-copy-reference{ type: "button", data: { 'clipboard-text': @merge_request.to_reference(full: true) } }
- .gl-dropdown-item-text-wrapper
- = _('Copy reference')
-
- - unless current_controller?('conflicts')
- - unless issuable_author_is_current_user(@merge_request)
- - if moved_mr_sidebar_enabled?
- %li.gl-dropdown-divider
- %hr.dropdown-divider
- .js-report-abuse-dropdown-item{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @merge_request.author.id, reported_from_url: merge_request_url(@merge_request) } }
-
-#js-report-abuse-drawer
+.js-mr-more-dropdown{ data: {
+ merge_request: @merge_request.to_json,
+ project_path: @project.full_path,
+ edit_url: edit_project_merge_request_path(@project, @merge_request),
+ is_current_user: issuable_author_is_current_user(@merge_request),
+ is_logged_in: current_user,
+ can_update_merge_request: can?(current_user, :update_merge_request, @merge_request),
+ open: @merge_request.open?,
+ merged: @merge_request.merged?,
+ source_project_missing: @merge_request.source_project_missing?,
+ clipboard_text: @merge_request.to_reference(full: true),
+ report_abuse_path: add_category_abuse_reports_path,
+ reported_user_id: @merge_request.author.id,
+ reported_from_url: merge_request_url(@merge_request),
+} }
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 9142893d400..7b815d996e0 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -25,7 +25,6 @@
%span.issuable-authored.d-none.d-sm-inline-block.gl-text-gray-500!
&middot;
#{s_('IssueList|created %{timeAgoString} by %{user}').html_safe % { timeAgoString: time_ago_with_tooltip(merge_request.created_at, placement: 'bottom'), user: link_to_member(@project, merge_request.author, avatar: false, extra_class: 'gl-text-gray-500!') }}
- = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author
- if merge_request.milestone
%span.issuable-milestone.d-none.d-sm-inline-block.gl-text-truncate.gl-max-w-26.gl-vertical-align-bottom
&nbsp;
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 15339becb74..dfa582f4c60 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -26,7 +26,7 @@
.detail-page-header-actions.gl-align-self-start.is-merge-request.js-issuable-actions.gl-display-flex
- if can_update_merge_request
- = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_button" }}) do
+ = render Pajamas::ButtonComponent.new(href: edit_project_merge_request_path(@project, @merge_request), button_options: {class: "gl-display-none gl-md-display-block js-issuable-edit", data: { qa_selector: "edit_title_button" }}) do
= _('Edit')
- if @merge_request.source_project
diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml
index 3e56148f777..5ea67376a86 100644
--- a/app/views/projects/merge_requests/_page.html.haml
+++ b/app/views/projects/merge_requests/_page.html.haml
@@ -16,7 +16,7 @@
- add_page_specific_style 'page_bundles/ci_status'
- add_page_startup_api_call @endpoint_metadata_url
-- if mr_action == 'diffs' && (!@file_by_file_default || !single_file_file_by_file?)
+- if mr_action == 'diffs' && !@file_by_file_default
- add_page_startup_api_call @endpoint_diff_batch_url
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version, diffs_batch_cache_key: @diffs_batch_cache_key } }
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index 0570d22529b..07bae4d2396 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -1,6 +1,16 @@
%h1.page-title.gl-font-size-h-display
= _('New merge request')
+- if @saml_groups.present?
+ = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c|
+ - c.with_body do
+ = s_('GroupSAML|Some branches are inaccessible because your SAML session has expired. To access the branches, select the group’s path to reauthenticate.')
+ - c.with_actions do
+ .gl-display-flex.gl-flex-wrap
+ - @saml_groups.each do |group|
+ = render Pajamas::ButtonComponent.new(href: sso_group_saml_providers_path(group, { token: group.saml_discovery_token, redirect: project_new_merge_request_branch_from_path(@source_project) }), button_options: { class: "gl-mr-3 gl-mb-3" }) do
+ = group.path
+
= gitlab_ui_form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f|
- if params[:nav_source].present?
= hidden_field_tag(:nav_source, params[:nav_source])
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 35e8b30e6e9..bec7cb3fd34 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -15,8 +15,10 @@
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
%ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0.js-tabs-affix
%li.commits-tab.new-tab
= link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do
@@ -32,8 +34,10 @@
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container.gl-display-flex.gl-justify-content-space-between
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
%ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.gl-display-flex.gl-flex-nowrap.gl-m-0.gl-p-0.js-tabs-affix
%li.commits-tab.new-tab
= link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do
diff --git a/app/views/projects/mirrors/_branch_filter.html.haml b/app/views/projects/mirrors/_branch_filter.html.haml
index b9db9898d49..7d90906bfe8 100644
--- a/app/views/projects/mirrors/_branch_filter.html.haml
+++ b/app/views/projects/mirrors/_branch_filter.html.haml
@@ -1,6 +1,9 @@
-.form-check.gl-mb-3
- = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input'
- = label_tag :only_protected_branches, _('Mirror only protected branches'), class: 'form-check-label'
- .form-text.text-muted
- = _('If enabled, only protected branches will be mirrored.')
- = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
+.form-group
+ = render Pajamas::CheckboxTagComponent.new(name: :only_protected_branches,
+ checkbox_options: { class: 'js-mirror-protected' },
+ label_options: { class: 'gl-mb-0!' }) do |c|
+ - c.with_label do
+ = _('Mirror only protected branches')
+ - c.with_help_text do
+ = _('If enabled, only protected branches will be mirrored.')
+ = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml
index 136f504084e..5b02d650989 100644
--- a/app/views/projects/mirrors/_mirror_repos_push.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml
@@ -8,9 +8,12 @@
= rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden'
= render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f }
= render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f }
- .form-check.gl-mb-3
- = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input'
- = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label'
- .form-text.text-muted
- - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe }
+ .form-group
+ = render Pajamas::CheckboxTagComponent.new(name: :keep_divergent_refs,
+ checkbox_options: { class: 'js-mirror-keep-divergent-refs' },
+ label_options: { class: 'gl-mb-0!' }) do |c|
+ - c.with_label do
+ = _('Keep divergent refs')
+ - c.with_help_text do
+ - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
+ = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe }
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index c8d4f02274b..c4630eec168 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -8,11 +8,10 @@
= form_tag network_path, method: :get, class: 'form-inline network-form' do |f|
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control gl-form-input input-mx-250 search-sha gl-mr-2'
= render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, icon: 'search')
- .inline.gl-ml-5
- .form-check.light
- = check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input'
- = label_tag :filter_ref, class: 'form-check-label' do
- %span= _("Begin with the selected commit")
+ .form-group{ class: 'gl-ml-5 gl-mb-n3!' }
+ = render Pajamas::CheckboxTagComponent.new(name: :filter_ref, checked: @options[:filter_ref]) do |c|
+ - c.with_label do
+ = _("Begin with the selected commit")
- if @commit
.network-graph.gl-bg-white.gl-overflow-scroll.gl-overflow-x-hidden{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } }
diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb
index 93b3c9911e2..7b5119d92e4 100644
--- a/app/views/projects/network/show.json.erb
+++ b/app/views/projects/network/show.json.erb
@@ -9,7 +9,7 @@
author: {
name: c.author_name,
email: c.author_email,
- icon: image_path(avatar_icon_for_email(c.author_email, 20))
+ icon: image_path(avatar_icon_for_email(c.author_email, 20, by_commit_email: true))
},
time: c.time,
space: c.spaces.first,
diff --git a/app/views/projects/pages/_waiting.html.haml b/app/views/projects/pages/_waiting.html.haml
index e8acadbabe3..0613ffc4809 100644
--- a/app/views/projects/pages/_waiting.html.haml
+++ b/app/views/projects/pages/_waiting.html.haml
@@ -1,7 +1,7 @@
.empty-state
.row.gl-align-items-center.gl-justify-content-center
.order-md-2
- = image_tag 'illustrations/pipelines_pending.svg'
+ = image_tag 'illustrations/empty-state/empty-pipeline-md.svg'
.row.gl-align-items-center.gl-justify-content-center
.text-content.gl-text-center.order-md-1
%h4= s_("GitLabPages|Waiting for the Pages Pipeline to complete...")
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 89e64d607a6..b8de364babc 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -2,15 +2,6 @@
- breadcrumb_title domain_presenter.domain
- page_title domain_presenter.domain
-- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
-
-- if verification_enabled && domain_presenter.unverified?
- = content_for :flash_message do
- = render Pajamas::AlertComponent.new(variant: :warning, dismissible: false) do |c|
- - c.with_body do
- .container-fluid.container-limited
- = _("This domain is not verified. You will need to verify ownership before access is enabled.")
-
%h1.page-title.gl-font-size-h-display
= _('Pages Domain')
= render 'projects/pages_domains/helper_text'
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 3ff370dfaa4..753bb77e755 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -16,7 +16,7 @@
.icon-container
= sprite_icon('clock', css_class: 'gl-top-0!')
= n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size
- = @pipeline.ref_text
+ = @pipeline.ref_text_legacy
- if @pipeline.finished_at
- duration = time_interval_in_words(@pipeline.duration)
- queued_duration = time_interval_in_words(@pipeline.queued_duration)
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index a7d670f8475..46e1cd07a17 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -9,11 +9,14 @@
- add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid })
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
- #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
+ - if Feature.enabled?(:pipeline_details_header_vue, @project)
+ #js-pipeline-details-header-vue{ data: js_pipeline_details_header_data(@project, @pipeline) }
+ - else
+ #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
= render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline
- - if @pipeline.commit.present?
+ - if @pipeline.commit.present? && !Feature.enabled?(:pipeline_details_header_vue, @project)
= render "projects/pipelines/info", commit: @pipeline.commit
- if pipeline_has_errors
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index a0a90fbe204..6b6aaaad802 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -15,7 +15,10 @@
- invite_group_top_margin = ''
- if can_admin_project_member?(@project)
.js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } }
- .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name, reload_page_on_submit: true.to_s } }
+ .js-import-project-members-modal{ data: { project_id: @project.id,
+ project_name: @project.name,
+ reload_page_on_submit: true.to_s,
+ users_limit_dataset: common_invite_modal_dataset(@project)[:users_limit_dataset] } }
- invite_group_top_margin = 'gl-md-mt-0 gl-mt-3'
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } }
diff --git a/app/views/projects/readme_templates/default.md.tt b/app/views/projects/readme_templates/default.md.tt
index ad1d6cce08d..779b87336ea 100644
--- a/app/views/projects/readme_templates/default.md.tt
+++ b/app/views/projects/readme_templates/default.md.tt
@@ -31,7 +31,7 @@ git push -uf origin <%= params[:default_branch] %>
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
-- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
+- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
diff --git a/app/views/projects/runners/_project_runners.html.haml b/app/views/projects/runners/_project_runners.html.haml
index 1d4e45c71b5..af8f39ce0ad 100644
--- a/app/views/projects/runners/_project_runners.html.haml
+++ b/app/views/projects/runners/_project_runners.html.haml
@@ -7,7 +7,8 @@
- if can?(current_user, :create_runner, @project)
= render Pajamas::ButtonComponent.new(href: new_project_runner_path(@project), variant: :confirm) do
= s_('Runners|New project runner')
- #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } }
+ .gl-display-inline
+ #js-project-runner-registration-dropdown{ data: { registration_token: @project.runners_token, project_id: @project.id } }
- else
= _('Please contact an admin to create runners.')
= link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'restrict-runner-registration-by-all-users-in-an-instance'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/settings/access_tokens/_form.html.haml b/app/views/projects/settings/access_tokens/_form.html.haml
new file mode 100644
index 00000000000..919462a0f62
--- /dev/null
+++ b/app/views/projects/settings/access_tokens/_form.html.haml
@@ -0,0 +1,14 @@
+- type = local_assigns.fetch(:type)
+
+= render 'shared/access_tokens/form',
+ ajax: true,
+ type: type,
+ path: project_settings_access_tokens_path(@project),
+ resource: @project,
+ token: @resource_access_token,
+ scopes: @scopes,
+ access_levels: ProjectMember.permissible_access_level_roles(current_user, @project),
+ default_access_level: Gitlab::Access::GUEST,
+ prefix: :resource_access_token,
+ description_prefix: :project_access_token,
+ help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml
index 26c08fcdfe4..df517b5d642 100644
--- a/app/views/projects/settings/access_tokens/index.html.haml
+++ b/app/views/projects/settings/access_tokens/index.html.haml
@@ -27,18 +27,8 @@
#js-new-access-token-app{ data: { access_token_type: type } }
- if current_user.can?(:create_resource_access_tokens, @project)
- = render 'shared/access_tokens/form',
- ajax: true,
- type: type,
- path: project_settings_access_tokens_path(@project),
- resource: @project,
- token: @resource_access_token,
- scopes: @scopes,
- access_levels: ProjectMember.permissible_access_level_roles(current_user, @project),
- default_access_level: Gitlab::Access::GUEST,
- prefix: :resource_access_token,
- description_prefix: :project_access_token,
- help_path: help_page_path('user/project/settings/project_access_tokens', anchor: 'scopes-for-a-project-access-token')
+ = render_if_exists 'projects/settings/access_tokens/form',
+ type: type
#js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true
} }
diff --git a/app/views/projects/settings/operations/_grafana_integration.html.haml b/app/views/projects/settings/operations/_grafana_integration.html.haml
deleted file mode 100644
index 69e42a6c4fb..00000000000
--- a/app/views/projects/settings/operations/_grafana_integration.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-.js-grafana-integration{ data: { operations_settings_endpoint: project_settings_operations_path(@project),
- grafana_integration: { url: grafana_integration_url, token: grafana_integration_masked_token, enabled: grafana_integration_enabled?.to_s } } }
diff --git a/app/views/projects/settings/operations/_metrics_dashboard.html.haml b/app/views/projects/settings/operations/_metrics_dashboard.html.haml
deleted file mode 100644
index 056d3e8102b..00000000000
--- a/app/views/projects/settings/operations/_metrics_dashboard.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project),
- help_page: help_page_path('operations/metrics/dashboards/settings'),
- external_dashboard: { url: metrics_external_dashboard_url,
- help_page: help_page_path('operations/metrics/dashboards/settings') },
- dashboard_timezone: { setting: metrics_dashboard_timezone.upcase } } }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index d44ebf1eb83..93ab98c1472 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,14 +2,7 @@
- breadcrumb_title _('Monitor Settings')
- @force_desktop_expanded_sidebar = true
-- if Feature.disabled?(:remove_monitor_metrics)
- = render 'projects/settings/operations/metrics_dashboard'
-
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
-
-- if Feature.disabled?(:remove_monitor_metrics)
- = render 'projects/settings/operations/grafana_integration'
-
= render_if_exists 'projects/settings/operations/status_page'
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 12404180362..36ace52df13 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -4,8 +4,7 @@
- @force_desktop_expanded_sidebar = true
= render "projects/branch_defaults/show"
-- if Feature.enabled?(:branch_rules, @project)
- = render "projects/branch_rules/show"
+= render "projects/branch_rules/show"
= render_if_exists "projects/push_rules/index"
= render "projects/mirrors/mirror_repos"
diff --git a/app/views/projects/settings/slacks/edit.html.haml b/app/views/projects/settings/slacks/edit.html.haml
new file mode 100644
index 00000000000..867b90655e3
--- /dev/null
+++ b/app/views/projects/settings/slacks/edit.html.haml
@@ -0,0 +1,20 @@
+- page_title _('Edit Slack integration')
+
+.row.gl-mt-3.gl-mb-3
+ .col-lg-3
+ %h4.gl-mt-0
+ = s_('Integrations|Edit project alias')
+
+ %p= s_('Integrations|You can use this alias in your Slack commands')
+ .col-lg-9
+ = form_errors(@slack_integration)
+ = form_for(@slack_integration, url: project_settings_slack_path(@project), method: :put, html: { class: 'gl-show-field-errors js-integration-settings-form'}) do |form|
+ .form-group.row
+ = form.label :alias, s_('Integrations|Enter your alias'), class: 'col-form-label'
+ .col-sm-10
+ = form.text_field :alias, class: 'form-control', placeholder: @slack_integration.alias, required: true
+
+ .footer-block.row-content-block
+ = form.submit _('Save changes'), pajamas_button: true
+ &nbsp;
+ = link_to _('Cancel'), edit_project_settings_integration_path(@project, @service), class: 'btn gl-button btn-cancel'
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 1df323e7451..53c3d16ee64 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -18,17 +18,17 @@
= form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "common-note-form tag-form js-quick-submit js-requires-input" do
.form-group.row
.col-sm-12
- = label_tag :tag_name, nil
+ = label_tag :tag_name, _('Tag name')
= text_field_tag :tag_name, params[:tag_name], required: true, autofocus: true, class: 'form-control', data: { qa_selector: "tag_name_field" }
.form-group.row
.col-sm-auto.create-from
- = label_tag :ref, 'Create from'
+ = label_tag :ref, _('Create from')
.js-new-tag-ref-selector{ data: { project_id: @project.id, default_branch_name: default_ref, hidden_input_name: 'ref' } }
.form-text.text-muted
= s_('TagsPage|Existing branch name, tag, or commit SHA')
.form-group.row
.col-sm-12
- = label_tag :message, nil
+ = label_tag :message, _('Message')
= text_area_tag :message, @message, required: false, class: 'form-control', rows: 5, data: { qa_selector: "tag_message_field" }
.form-text.text-muted
= tag_description_help_text
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 3124f47c832..5127972c406 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -1,6 +1,6 @@
- user = user_email = nil
-- if @tag.tagger
- - user_email = @tag.tagger.email
+- if @tag.user_email
+ - user_email = @tag.user_email
- user = User.find_by_any_email(user_email)
- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project)
- breadcrumb_title @tag.name
diff --git a/app/views/protected_branches/_create_protected_branch.html.haml b/app/views/protected_branches/_create_protected_branch.html.haml
index b4765ab49c2..799f6aa6031 100644
--- a/app/views/protected_branches/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/_create_protected_branch.html.haml
@@ -3,12 +3,12 @@
= dropdown_tag(_('Select'),
options: { toggle_class: 'js-allowed-to-merge wide',
dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_merge_dropdown_content', dropdown_testid: 'allowed-to-merge-dropdown',
- data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'allowed_to_merge_dropdown' }})
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes', qa_selector: 'select_allowed_to_merge_dropdown' }})
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag(_('Select'),
options: { toggle_class: "js-allowed-to-push js-multiselect wide",
dropdown_class: 'dropdown-menu-selectable capitalize-header', dropdown_qa_selector: 'allowed_to_push_dropdown_content' , dropdown_testid: 'allowed-to-push-dropdown',
- data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'allowed_to_push_dropdown' }})
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes', qa_selector: 'select_allowed_to_push_dropdown' }})
= render 'protected_branches/shared/create_protected_branch', protected_branch_entity: protected_branch_entity
diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml
index 9bc224b2e78..d97347b89de 100644
--- a/app/views/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml
@@ -1,9 +1,9 @@
= gitlab_ui_form_for [protected_branch_entity, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f|
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
= render Pajamas::CardComponent.new(card_options: { class: "gl-mb-5" }) do |c|
- - c.header do
+ - c.with_header do
= s_("ProtectedBranch|Protect a branch")
- - c.body do
+ - c.with_body do
= form_errors(@protected_branch)
.form-group.row
= f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
@@ -13,7 +13,7 @@
- else
= render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
.form-text.text-muted
- - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard')
+ - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules')
- wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
- placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }
- if protected_branch_entity.is_a?(Group)
@@ -38,7 +38,7 @@
- force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url }
= (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe
= render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity
- - c.footer do
+ - c.with_footer do
= f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true
.js-alert-protected-branch-created-container.gl-mb-5
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index 6c8ab5654a0..986bc53fd81 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -11,7 +11,6 @@
.row.gl-flex-grow-1
.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?
%p.gl-text-center= html_escape(_('%{gitlab_experience_text}. We won\'t share this information with anyone.')) % { gitlab_experience_text: gitlab_experience_text }
@@ -23,7 +22,7 @@
'aria-live' => 'assertive',
data: { testid: 'welcome-form' } }) do |f|
= render Pajamas::CardComponent.new do |c|
- - c.body do
+ - c.with_body do
.devise-errors
= render 'devise/shared/error_messages', resource: current_user
.row
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 99558f61b25..7399f51d7f8 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,5 +1,3 @@
-= render_if_exists 'shared/promotions/promote_advanced_search'
-
.gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden
= render partial: 'search/results_status' unless @search_objects.to_a.empty?
= render partial: 'search/results_list'
diff --git a/app/views/search/_results_list.html.haml b/app/views/search/_results_list.html.haml
index fcbf0ba4452..ff79f003e7d 100644
--- a/app/views/search/_results_list.html.haml
+++ b/app/views/search/_results_list.html.haml
@@ -1,3 +1,6 @@
+.advanced-search-promote
+ = render_if_exists 'shared/promotions/promote_advanced_search'
+
- if @timeout
= render partial: "search/results/timeout"
- elsif @search_results.respond_to?(:failed?) && @search_results.failed?
diff --git a/app/views/shared/_alert_info.html.haml b/app/views/shared/_alert_info.html.haml
index 30dfc87b9bf..e6dbcfbff1c 100644
--- a/app/views/shared/_alert_info.html.haml
+++ b/app/views/shared/_alert_info.html.haml
@@ -1,3 +1,3 @@
= render Pajamas::AlertComponent.new(variant: :info, dismissible: true) do |c|
- = c.body do
+ - c.with_body do
= body
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index c468b3a2001..6d8d4f4cab9 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -6,7 +6,7 @@
svg_path: 'illustrations/autodevops.svg',
banner_options: { class: 'js-autodevops-banner auto-devops-callout', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } },
close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c|
- - c.title do
+ - c.with_title do
= s_('AutoDevOps|Auto DevOps')
%p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
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 ac7d56520f7..f4af3ea70d4 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -2,11 +2,11 @@
= render Pajamas::AlertComponent.new(alert_options: { class: 'auto-devops-implicitly-enabled-banner', data: { qa_selector: 'auto_devops_banner_content' } },
close_button_options: { class: 'hide-auto-devops-implicitly-enabled-banner',
data: { project_id: project.id }}) do |c|
- = c.body do
+ - c.with_body do
= s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found.")
- unless Gitlab.config.registry.enabled
%div
= _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.')
- = c.actions do
+ - c.with_actions do
= 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/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
index a2fed883739..2f470d5ef53 100644
--- a/app/views/shared/_broadcast_message.html.haml
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -3,7 +3,8 @@
- preview = local_assigns.fetch(:preview, false)
- unless message.notification?
- .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert", class: "js-broadcast-notification-#{message.id} #{message.theme}" }
+ .gl-broadcast-message.broadcast-banner-message.banner{ role: "alert",
+ class: "js-broadcast-notification-#{message.id} #{message.theme}", data: { testid: 'banner-broadcast-message' } }
.gl-broadcast-message-content
.gl-broadcast-message-icon
= sprite_icon(icon_name)
@@ -19,21 +20,22 @@
icon: 'close',
size: :small,
button_options: { class: 'gl-close-btn-color-inherit gl-broadcast-message-dismiss js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601 } },
- icon_classes: 'gl-mx-3! gl-text-white')
+ icon_classes: 'gl-text-white')
- else
- notification_class = "js-broadcast-notification-#{message.id}"
- notification_class << ' preview' if preview
- .broadcast-message.broadcast-notification-message.mt-2{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } }
- = sprite_icon(icon_name, css_class: 'vertical-align-text-top')
- - if message.message.present?
- %h2.gl-sr-only
- = s_("Admin message")
- = render_broadcast_message(message)
- - else
- = yield
+ .gl-broadcast-message.broadcast-notification-message.gl-mt-3{ role: "alert", class: notification_class, data: { qa_selector: 'broadcast_notification_container' } }
+ .gl-broadcast-message-content
+ .gl-broadcast-message-icon
+ = sprite_icon(icon_name, css_class: 'vertical-align-text-top')
+ - if message.message.present?
+ %h2.gl-sr-only
+ = s_("Admin message")
+ = render_broadcast_message(message)
+ - else
+ = yield
- if !preview
- = render Pajamas::ButtonComponent.new(variant: :link,
+ = render Pajamas::ButtonComponent.new(category: :tertiary,
icon: 'close',
size: :small,
- button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } },
- icon_classes: 'gl-mx-3! gl-text-gray-700')
+ button_options: { class: 'js-dismiss-current-broadcast-notification', 'aria-label': _('Close'), data: { id: message.id, expire_date: message.ends_at.iso8601, qa_selector: 'close_button' } })
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
index e3f2e1aa436..657fef1f74d 100644
--- a/app/views/shared/_choose_avatar_button.html.haml
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -1 +1 @@
-= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KB.")
+= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KiB.")
diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml
index 6e5f1cb063c..be96e77dbd4 100644
--- a/app/views/shared/_custom_attributes.html.haml
+++ b/app/views/shared/_custom_attributes.html.haml
@@ -1,9 +1,9 @@
- return unless custom_attributes.present?
= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c|
- - c.header do
+ - c.with_header do
= link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md'))
- - c.body do
+ - c.with_body do
%ul.content-list
- custom_attributes.each do |custom_attribute|
%li
diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml
index 03534bf78d1..82b4a314b59 100644
--- a/app/views/shared/_event_filter.html.haml
+++ b/app/views/shared/_event_filter.html.haml
@@ -1,8 +1,10 @@
- show_group_events = local_assigns.fetch(:show_group_events, false)
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.event-filter.scrolling-tabs.nav.nav-tabs
= event_filter_link EventFilter::ALL, _('All'), s_('EventFilterBy|Filter by all')
- if event_filter_visible(:repository)
diff --git a/app/views/shared/_ide_root.html.haml b/app/views/shared/_ide_root.html.haml
index 848ff1e5728..db3e76e188c 100644
--- a/app/views/shared/_ide_root.html.haml
+++ b/app/views/shared/_ide_root.html.haml
@@ -3,9 +3,8 @@
-# Fix for iOS 13+, the height of the page is actually less than
-# 100vh because of the presence of the bottom bar
-- @body_class = 'gl-max-h-full gl-fixed'
-#ide.gl--flex-center.gl-h-full{ data: data }
- .gl-text-center
- = gl_loading_icon(size: 'md')
- %h2.clgray= loading_text
+#ide.gl-h-full{ data: data }
+ .web-ide-loader.gl-display-flex.gl-justify-content-center.gl-align-items-center.gl-flex-direction-column.gl-h-full.gl-mr-auto.gl-ml-auto
+ = brand_header_logo
+ %h3.clblack.gl-mt-6= loading_text
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index d10f514dc58..668ac908703 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -25,7 +25,7 @@
alert_options: { class: 'gl-mt-3 js-import-url-error hide' },
dismissible: false,
close_button_options: { class: 'js-close-2fa-enabled-success-alert' }) do |c|
- = c.body do
+ - c.with_body do
= s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
= render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only
.row
diff --git a/app/views/shared/_model_version_conflict.html.haml b/app/views/shared/_model_version_conflict.html.haml
index 134dcf8db7f..8ab821c0435 100644
--- a/app/views/shared/_model_version_conflict.html.haml
+++ b/app/views/shared/_model_version_conflict.html.haml
@@ -1,6 +1,6 @@
= render Pajamas::AlertComponent.new(variant: :danger,
dismissible: false,
alert_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
- link_to_model = link_to(model_name, link_path, target: '_blank', rel: 'noopener noreferrer')
= _("Someone edited this %{model_name} at the same time you did. Please check out the %{link_to_model} and make sure your changes will not unintentionally remove theirs.").html_safe % { model_name: model_name, link_to_model: link_to_model }
diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml
index 6bc6d0943c9..fb3dfba2691 100644
--- a/app/views/shared/_new_merge_request_checkbox.html.haml
+++ b/app/views/shared/_new_merge_request_checkbox.html.haml
@@ -1,8 +1,9 @@
-.form-check.gl-mt-3
+.form-group.gl-mt-3
- nonce = SecureRandom.hex
- = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request form-check-input', id: "create_merge_request-#{nonce}"
- = label_tag "create_merge_request-#{nonce}", class: 'form-check-label' do
- - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" }
- - translation = _('Start a %{new_merge_request} with these changes') % translation_variables
- #{ translation.html_safe }
-
+ = render Pajamas::CheckboxTagComponent.new(name: 'create_merge_request',
+ checked: true,
+ checkbox_options: { class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" }) do |c|
+ - c.with_label do
+ - translation_variables = { new_merge_request: "<strong>#{_('new merge request')}</strong>" }
+ - translation = _('Start a %{new_merge_request} with these changes') % translation_variables
+ #{ translation.html_safe }
diff --git a/app/views/shared/_new_nav_announcement.html.haml b/app/views/shared/_new_nav_announcement.html.haml
new file mode 100644
index 00000000000..8cabab09ec2
--- /dev/null
+++ b/app/views/shared/_new_nav_announcement.html.haml
@@ -0,0 +1,33 @@
+- return unless show_new_navigation_callout?
+
+- changes_url = 'https://gitlab.com/groups/gitlab-org/-/epics/9044#whats-different'
+- vision_url = 'https://about.gitlab.com/blog/2023/05/01/gitlab-product-navigation/'
+- design_url = 'https://about.gitlab.com/blog/2023/05/15/overhauling-the-navigation-is-like-building-a-dream-home/'
+- feedback_url = 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005'
+- docs_url = help_page_path('tutorials/left_sidebar/index')
+
+- changes_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: changes_url }
+- vision_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: vision_url }
+- design_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: design_url }
+- link_end = '</a>'.html_safe
+
+- welcome_text = _('For the next few releases, you can go to your avatar at any time to turn the new navigation on and off.')
+- cta_text = _('Read more about the %{changes_link_start}changes%{link_end}, the %{vision_link_start}vision%{link_end}, and the %{design_link_start}design%{link_end}.' % { changes_link_start: changes_link_start,
+ vision_link_start: vision_link_start,
+ design_link_start: design_link_start,
+ link_end: link_end}).html_safe # rubocop:disable Gettext/StaticIdentifier
+
+= render Pajamas::AlertComponent.new(dismissible: true, title: _('Welcome to a new navigation experience'),
+ alert_options: { class: 'js-new-navigation-callout', data: { feature_id: "new_navigation_callout", dismiss_endpoint: callouts_path }}) do |c|
+ - c.with_body do
+ %p
+ = welcome_text
+ = cta_text
+ - c.with_actions do
+ = render Pajamas::ButtonComponent.new(variant: :confirm,
+ href: docs_url,
+ button_options: { class: 'gl-alert-action', data: { track_action: 'click_button', track_label: 'banner_nav_learn_more' } }) do |c|
+ = _('Learn more')
+ = render Pajamas::ButtonComponent.new(href: feedback_url,
+ button_options: { data: { track_action: 'click_button', track_label: 'banner_nav_provide_feedback' } }) do |c|
+ = _('Provide feedback')
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index 76830230cf6..e0d385024cd 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -2,8 +2,8 @@
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'js-no-password-message' },
close_button_options: { class: 'js-hide-no-password-message' }) do |c|
- = c.body do
+ - c.with_body do
= no_password_message
- = c.actions do
+ - c.with_actions do
= link_to _('Remind later'), '#', class: 'js-hide-no-password-message gl-alert-action btn btn-confirm btn-md gl-button'
= link_to _("Don't show again"), profile_path(user: { hide_no_password: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index be1df54a432..e9c0858e090 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -2,8 +2,8 @@
= render Pajamas::AlertComponent.new(variant: :warning,
alert_options: { class: 'js-no-ssh-message' },
close_button_options: { class: 'js-hide-no-ssh-message'}) do |c|
- = c.body do
+ - c.with_body do
= s_("MissingSSHKeyWarningLink|You can't push or pull repositories using SSH until you add an SSH key to your profile.")
- = c.actions do
+ - c.with_actions do
= link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "gl-alert-action btn btn-confirm btn-md gl-button"
= link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: { hide_no_ssh_key: true }), method: :put, role: 'button', class: 'gl-alert-action btn btn-default btn-md gl-button'
diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml
index 0af378cb883..79d0231536b 100644
--- a/app/views/shared/_outdated_browser.html.haml
+++ b/app/views/shared/_outdated_browser.html.haml
@@ -1,6 +1,6 @@
- if outdated_browser?
= render Pajamas::AlertComponent.new(variant: :danger, dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= s_('OutdatedBrowser|GitLab may not work properly, because you are using an outdated web browser.')
%br
- browser_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('install/requirements', anchor: 'supported-web-browsers') }
diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml
index 60be03c6631..ce49193e27b 100644
--- a/app/views/shared/_project_limit.html.haml
+++ b/app/views/shared/_project_limit.html.haml
@@ -2,8 +2,8 @@
= render Pajamas::AlertComponent.new(variant: :warning,
dismissible: false,
alert_options: { class: 'project-limit-message' }) do |c|
- = c.body do
+ - c.with_body do
= _("You won't be able to create new projects because you have reached your project limit.")
- = c.actions do
+ - c.with_actions do
= 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/_repository_size_limit_setting_registration_features_cta.html.haml b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml
index 9fe1e3087f6..0d084a99528 100644
--- a/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml
+++ b/app/views/shared/_repository_size_limit_setting_registration_features_cta.html.haml
@@ -3,7 +3,7 @@
.row
.form-group.col-md-9
= form.label :disabled_repository_size_limit, class: 'label-bold' do
- = _('Repository size limit (MB)')
+ = _('Repository size limit (MiB)')
= form.number_field :disabled_repository_size_limit, value: '', class: 'form-control', disabled: true
%span.form-text.text-muted
= render 'shared/registration_features_discovery_message'
diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml
index 700ffa7aa12..108d846e3ee 100644
--- a/app/views/shared/_service_ping_consent.html.haml
+++ b/app/views/shared/_service_ping_consent.html.haml
@@ -1,10 +1,10 @@
- if session[:ask_for_usage_stats_consent]
= render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c|
- = c.body do
+ - c.with_body do
- docs_link = link_to _('collect usage information'), help_page_path('user/admin_area/settings/usage_statistics.md'), class: 'gl-link'
- 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 }
- = c.actions do
+ - c.with_actions do
- 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 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-confirm'
diff --git a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index ff4b2de2286..290152d5803 100644
--- a/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -4,9 +4,9 @@
dismiss_endpoint: callouts_path,
defer_links: 'true' }},
close_button_options: { data: { testid: 'close-account-recovery-regular-check-callout' }}) do |c|
- = c.body do
+ - c.with_body do
= s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.')
= link_to _('Learn more.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'recovery-codes'), target: '_blank', rel: 'noopener noreferrer'
- = c.actions do
+ - c.with_actions do
= link_to profile_two_factor_auth_path, class: 'deferred-link btn gl-alert-action btn-confirm btn-md gl-button' do
= s_('Profiles|Manage two-factor authentication')
diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml
index aeaccdfa54b..803f6f9efce 100644
--- a/app/views/shared/_web_ide_button.html.haml
+++ b/app/views/shared/_web_ide_button.html.haml
@@ -1,5 +1,5 @@
- type = blob ? 'blob' : 'tree'
- button_data = web_ide_button_data({ blob: blob })
-- fork_options = fork_modal_options(@project, @ref, @path, blob)
+- fork_options = fork_modal_options(@project, blob)
.gl-display-inline-block{ data: { options: button_data.merge(fork_options).to_json, web_ide_promo_popover_img: image_path('web-ide-promo-popover.svg') }, id: "js-#{type}-web-ide-link" }
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index eada58091b7..ac359d37c49 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -1,5 +1,5 @@
- ajax = local_assigns.fetch(:ajax, false)
-- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type })
+- title = local_assigns.fetch(:title, s_('AccessTokens|Add a %{type}') % { type: type })
- prefix = local_assigns.fetch(:prefix, :personal_access_token)
- description_prefix = local_assigns.fetch(:description_prefix, prefix)
- help_path = local_assigns.fetch(:help_path)
@@ -10,7 +10,7 @@
%h5.gl-mt-0
= title
%p.profile-settings-content
- = _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
+ = s_("AccessTokens|Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f|
@@ -19,11 +19,11 @@
.row
.form-group.col
.row
- = f.label :name, _('Token name'), class: 'label-bold col-md-12'
+ = f.label :name, s_('AccessTokens|Token name'), class: 'label-bold col-md-12'
.col-md-6
- resource_type = resource.is_a?(Group) ? "group" : "project"
= 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. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
+ %span.form-text.text-muted.col-md-12#access_token_help_text= s_("AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members.") % { resource_type: resource_type }
.row
.col
@@ -33,18 +33,18 @@
- if resource
.row
.form-group.col-md-6
- = label_tag :access_level, _("Select a role"), class: "label-bold"
+ = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold"
.select-wrapper
= select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control 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
%b{ :'aria-describedby' => 'select_scope_help_text' }
- = s_('Tokens|Select scopes')
+ = s_('AccessTokens|Select scopes')
%p.text-secondary#select_scope_help_text
- = s_('Tokens|Scopes set the permission levels granted to the token.')
+ = s_('AccessTokens|Scopes set the permission levels granted to the token.')
= link_to _("Learn more."), help_path, target: '_blank', rel: 'noopener noreferrer'
= render 'shared/tokens/scopes_form', prefix: prefix, description_prefix: description_prefix, token: token, scopes: scopes, f: f
.gl-mt-3
- = f.submit _('Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true
+ = f.submit s_('AccessTokens|Create %{type}') % { type: type }, data: { qa_selector: 'create_token_button' }, pajamas_button: true
diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml
index 2bf6baaf608..77854439bbb 100644
--- a/app/views/shared/admin/_admin_note.html.haml
+++ b/app/views/shared/admin/_admin_note.html.haml
@@ -1,7 +1,7 @@
- if @group.admin_note&.note?
- text = @group.admin_note.note
= render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
- - c.header do
+ - c.with_header do
= s_('Admin|Admin notes')
- - c.body do
+ - c.with_body do
%p= text
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 1ab9e288a9e..387a83873b5 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -8,8 +8,8 @@
.row.empty-state
.col-12
- .svg-content
- = image_tag 'illustrations/issues.svg'
+ .svg-content.svg-150
+ = image_tag 'illustrations/empty-state/empty-issues-md.svg'
.col-12
.text-content
- if has_filter_bar_param?
diff --git a/app/views/shared/errors/_gitaly_unavailable.html.haml b/app/views/shared/errors/_gitaly_unavailable.html.haml
index ba3293a3f75..9b60071cb91 100644
--- a/app/views/shared/errors/_gitaly_unavailable.html.haml
+++ b/app/views/shared/errors/_gitaly_unavailable.html.haml
@@ -2,5 +2,5 @@
variant: :danger,
dismissible: false,
title: reason) do |c|
- = c.body do
+ - c.with_body do
= s_('The git server, Gitaly, is not available at this time. Please contact your administrator.')
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
index 16e89463a4b..ba968c6b2d2 100644
--- a/app/views/shared/file_hooks/_index.html.haml
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -12,9 +12,9 @@
.col-lg-8.gl-mb-3
- if file_hooks.any?
= render Pajamas::CardComponent.new do |c|
- - c.header do
+ - c.with_header do
= _('File Hooks (%{count})') % { count: file_hooks.count }
- - c.body do
+ - c.with_body do
%ul.content-list
- file_hooks.each do |file|
%li
@@ -22,5 +22,5 @@
= File.basename(file)
- else
= render Pajamas::CardComponent.new do |c|
- - c.body do
+ - c.with_body do
.nothing-here-block= _('No file hooks found.')
diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml
index 8b5b4b6e5fa..1971c2da913 100644
--- a/app/views/shared/hook_logs/_content.html.haml
+++ b/app/views/shared/hook_logs/_content.html.haml
@@ -13,7 +13,7 @@
= render Pajamas::AlertComponent.new(title: _('Internal error occurred while delivering this webhook.'),
variant: :danger,
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= _('Error: %{error}') % { error: hook_log.internal_error_message }
%h4= _('Response')
@@ -41,6 +41,3 @@
- hook_log.request_headers.each do |k, v|
<span class="gl-font-weight-bold">#{k}:</span> #{v}
%br
-
-
-
diff --git a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
index de4439a8fde..c77cc687e4f 100644
--- a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
+++ b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
@@ -3,7 +3,7 @@
variant: :warning,
dismissible: false,
alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c|
- = c.body do
+ - c.with_body do
- help_page_link = help_page_url('user/project/integrations/gitlab_slack_application')
- learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link }
@@ -13,7 +13,7 @@
variant: :warning,
dismissible: false,
alert_options: { class: 'gl-mt-5', data: { testid: "slack-notifications-deprecation" } }) do |c|
- = c.body do
+ - c.with_body do
- help_page_link = help_page_url('user/project/integrations/gitlab_slack_application')
- learn_more_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_link }
diff --git a/app/views/shared/integrations/gitlab_slack_application/_help.html.haml b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml
new file mode 100644
index 00000000000..0956f1183cb
--- /dev/null
+++ b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml
@@ -0,0 +1,10 @@
+.info-well
+ .well-segment
+ %p
+ = s_("SlackIntegration|This integration allows users to perform common operations on this project by entering slash commands in Slack.")
+ = link_to _('Learn more'), help_page_path('user/project/integrations/gitlab_slack_application.md')
+ %p
+ = s_("SlackIntegration|See the list of available commands in Slack after setting up this integration by entering")
+ %kbd.inline /gitlab help
+- if integration.project_level?
+ = render "shared/integrations/#{integration.to_param}/slack_integration_form", integration: integration
diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml
new file mode 100644
index 00000000000..b22a6eeca90
--- /dev/null
+++ b/app/views/shared/integrations/gitlab_slack_application/_slack_button.html.haml
@@ -0,0 +1,4 @@
+= link_to add_to_slack_link(project, slack_app_id), class: 'btn btn-default gl-button gl-pr-6!' do
+ = image_tag 'illustrations/slack_logo.svg', class: 'gl-icon gl-button-icon gl-w-9! gl-h-9! gl-my-n3! gl-mr-0!'
+ %strong.gl-button-text
+ = label
diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml
new file mode 100644
index 00000000000..5c9f77f8c12
--- /dev/null
+++ b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml
@@ -0,0 +1,32 @@
+- slack_integration = integration.slack_integration
+- if slack_integration
+ %table.gl-table.gl-w-full
+ %colgroup
+ %col{ width: "25%" }
+ %col{ width: "35%" }
+ %col{ width: "20%" }
+ %col
+ %thead
+ %tr
+ %th= s_('SlackIntegration|Team name')
+ %th= s_('SlackIntegration|Project alias')
+ %th= _('Created')
+ %th
+ %tr
+ %td{ class: 'gl-py-3!' }
+ = slack_integration.team_name
+ %td{ class: 'gl-py-3!' }
+ = slack_integration.alias
+ %td{ class: 'gl-py-3!' }
+ = time_ago_with_tooltip(slack_integration.created_at)
+ %td{ class: 'gl-py-3!' }
+ .controls
+ - project = integration.project
+ = link_to _('Edit'), edit_project_settings_slack_path(project), class: 'btn gl-button btn-default'
+ = link_to sprite_icon('remove', css_class: 'gl-icon'), project_settings_slack_path(project), method: :delete, class: 'btn gl-button btn-danger btn-danger-secondary', aria: { label: s_('SlackIntegration|Remove project') }, data: { confirm_btn_variant: "danger", confirm: s_('SlackIntegration|Are you sure you want to remove this project from the GitLab for Slack app?') }
+ .gl-my-5
+ = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Reinstall GitLab for Slack app')
+ %p
+ = html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application.md', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe}
+- else
+ = render 'shared/integrations/gitlab_slack_application/slack_button', project: @project, label: s_('SlackIntegration|Install GitLab for Slack app')
diff --git a/app/views/shared/integrations/gitlab_slack_application/_top.html.haml b/app/views/shared/integrations/gitlab_slack_application/_top.html.haml
new file mode 100644
index 00000000000..56200deac6d
--- /dev/null
+++ b/app/views/shared/integrations/gitlab_slack_application/_top.html.haml
@@ -0,0 +1,5 @@
+- if session.delete(:slack_install_success)
+ = render Pajamas::AlertComponent.new(title: s_('SlackIntegration|GitLab for Slack was successfully installed.'),
+ variant: :success) do |c|
+ - c.with_body do
+ = s_('SlackIntegration|You can now close this window and go to your Slack workspace.')
diff --git a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
index beeb328aedf..0264196f60c 100644
--- a/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
+++ b/app/views/shared/integrations/prometheus/_custom_metrics.html.haml
@@ -7,12 +7,12 @@
.col-lg-9
= render Pajamas::CardComponent.new(header_options: { class: 'gl-display-flex gl-align-items-center' }, body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 custom-monitored-metrics js-panel-custom-monitored-metrics', data: { active_custom_metrics: project_prometheus_metrics_path(project), environments_data: environments_list_data, service_active: "#{integration.active}" } }) do |c|
- - c.header do
+ - c.with_header do
%strong
= s_('PrometheusService|Custom metrics')
= gl_badge_tag 0, nil, class: 'gl-ml-2 js-custom-monitored-count'
= link_to s_('PrometheusService|New metric'), new_project_prometheus_metric_path(project), class: 'btn gl-button btn-confirm gl-ml-auto js-new-metric-button hidden'
- - c.body do
+ - c.with_body do
.flash-container.hidden
.flash-warning
.flash-text
diff --git a/app/views/shared/integrations/prometheus/_metrics.html.haml b/app/views/shared/integrations/prometheus/_metrics.html.haml
index 7cd4eeee5f8..cb78faa383a 100644
--- a/app/views/shared/integrations/prometheus/_metrics.html.haml
+++ b/app/views/shared/integrations/prometheus/_metrics.html.haml
@@ -9,11 +9,11 @@
.col-lg-9
= render Pajamas::CardComponent.new(body_options: { class: 'gl-p-0' }, card_options: { class: 'gl-mb-5 js-panel-monitored-metrics', data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') }}) do |c|
- - c.header do
+ - c.with_header do
%strong
= s_('PrometheusService|Common metrics')
= gl_badge_tag 0, nil, class: 'js-monitored-count'
- - c.body do
+ - c.with_body do
.loading-metrics.js-loading-metrics
%p.m-3
= gl_loading_icon(inline: true, css_class: 'metrics-load-spinner')
@@ -24,12 +24,12 @@
%ul.list-unstyled.metrics-list.hidden.js-metrics-list
= render Pajamas::CardComponent.new(body_options: { class: 'hidden gl-p-0' }, card_options: { class: 'hidden js-panel-missing-env-vars' }) do |c|
- - c.header do
+ - c.with_header do
= sprite_icon('chevron-lg-right', css_class: 'panel-toggle js-panel-toggle-right')
= sprite_icon('chevron-lg-down', css_class: 'panel-toggle js-panel-toggle-down hidden')
= s_('PrometheusService|Missing environment variable')
= gl_badge_tag 0, nil, class: 'js-env-var-count'
- - c.body do
+ - c.with_body do
.flash-container
.flash-notice
.flash-text
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 76678c48a86..b8f98c28574 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -13,7 +13,7 @@
- if @can_bulk_update
.check-all-holder.gl-display-none.gl-sm-display-block.hidden.gl-float-left.gl-mr-3.gl-line-height-36
= render Pajamas::CheckboxTagComponent.new(name: 'check-all-issues', value: nil) do |c|
- = c.label do
+ - c.with_label do
%span.gl-sr-only
= _('Select all')
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 09162e6a349..ee1ca364b07 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -46,7 +46,7 @@
.js-sidebar-milestone-widget-root{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- if in_group_context_with_iterations
- .block.gl-collapse-empty{ data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
+ .block.gl-collapse-empty{ data: { testid: '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[:show_crm_contacts]
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 634e927f891..01f1dbdb3cf 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -42,7 +42,7 @@
- if source_level < target_level
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-4' }) do |c|
- = c.body do
+ - c.with_body do
= visibilityMismatchString
%br
= _('Review the target project before submitting to avoid exposing %{source} changes.') % { source: source_visibility }
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 8e9793cdba5..051a1a75f2b 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -12,7 +12,7 @@
.form-check.gl-pl-0
= hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
= render Pajamas::CheckboxTagComponent.new(name: 'merge_request[force_remove_source_branch]', checked: issuable.force_remove_source_branch?, value: '1', checkbox_options: { class: 'js-form-update' }) do |c|
- = c.label do
+ - c.with_label do
= _("Delete source branch when merge request is accepted.")
- if !project.squash_never?
@@ -20,14 +20,14 @@
- if project.squash_always?
= hidden_field_tag 'merge_request[squash]', '1', id: nil
= render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: project.squash_enabled_by_default?, value: '1', checkbox_options: { class: 'js-form-update', disabled: true }) do |c|
- = c.label do
+ - c.with_label do
= _("Squash commits when merge request is accepted.")
= link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
- = c.help_text do
+ - c.with_help_text do
= _('Required in this project.')
- else
= hidden_field_tag 'merge_request[squash]', '0', id: nil
= render Pajamas::CheckboxTagComponent.new(name: 'merge_request[squash]', checked: issuable_squash_option?(issuable, project), value: '1', checkbox_options: { class: 'js-form-update' }) do |c|
- = c.label do
+ - c.with_label do
= _("Squash commits when merge request is accepted.")
= link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index b27fd8ab7d2..1da0b82b634 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -6,12 +6,12 @@
- if @add_related_issue
.form-group
- .form-check
- = check_box_tag :add_related_issue, @add_related_issue.iid, true, class: 'form-check-input'
- = label_tag :add_related_issue, class: 'form-check-label' do
+ = render Pajamas::CheckboxTagComponent.new(name: :add_related_issue, value: @add_related_issue.iid, checked: true) do |c|
+ - c.with_label do
- add_related_issue_link = link_to "\##{@add_related_issue.iid}", issue_path(@add_related_issue), class: ['has-tooltip'], title: @add_related_issue.title
#{_('Relate to %{issuable_type} %{add_related_issue_link}').html_safe % { issuable_type: @add_related_issue.issue_type, add_related_issue_link: add_related_issue_link }}
- %p.text-muted= _('Adds this %{issuable_type} as related to the %{issuable_type} it was created from') % { issuable_type: @add_related_issue.issue_type }
+ - c.with_help_text do
+ = _('Adds this %{issuable_type} as related to the %{issuable_type} it was created from') % { issuable_type: @add_related_issue.issue_type }
- if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable)
.form-group
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index be836f4b8a9..36000f3cc67 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -8,7 +8,7 @@
- if issuable.respond_to?(:draft?)
.gl-pt-3
= render Pajamas::CheckboxTagComponent.new(name: 'mark_as_draft', checkbox_options: { class: 'js-toggle-draft' }) do |c|
- = c.label do
+ - c.with_label do
= s_('MergeRequests|Mark as draft')
- = c.help_text do
+ - c.with_help_text do
= s_('MergeRequests|Drafts cannot be merged until marked ready.')
diff --git a/app/views/shared/issue_type/_details_content.html.haml b/app/views/shared/issue_type/_details_content.html.haml
index fdbe247c6ba..40a02fddbf3 100644
--- a/app/views/shared/issue_type/_details_content.html.haml
+++ b/app/views/shared/issue_type/_details_content.html.haml
@@ -2,7 +2,7 @@
- api_awards_path = local_assigns.fetch(:api_awards_path, nil)
.issue-details.issuable-details.js-issue-details
- .detail-page-description.content-block.js-detail-page-description.gl-pt-4.gl-pb-0.gl-border-none
+ .detail-page-description.content-block.js-detail-page-description.gl-pt-3.gl-pb-0.gl-border-none
#js-issuable-app{ data: { initial: issuable_initial_data(issuable).to_json,
issuable_id: issuable.id,
full_path: @project.full_path,
diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml
index 31625c22a94..45e34a63f91 100644
--- a/app/views/shared/members/_requests.html.haml
+++ b/app/views/shared/members/_requests.html.haml
@@ -6,12 +6,12 @@
- return if requesters.empty?
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-requests' } }, body_options: { class: 'gl-p-0' }) do |c|
- - c.header do
+ - c.with_header do
= _('Users requesting access to')
%strong= membership_source.name
= gl_badge_tag 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')
- - c.body do
+ - c.with_body do
%ul.content-list.members-list
= render partial: 'shared/members/member',
collection: requesters, as: :member,
diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml
index a63702661d0..3774fb0869f 100644
--- a/app/views/shared/milestones/_description.html.haml
+++ b/app/views/shared/milestones/_description.html.haml
@@ -9,5 +9,7 @@
- if milestone.try(:description).present?
%div{ data: { qa_selector: "milestone_description_content" } }
- .description.md.gl-px-0.gl-pt-4
+ .description.md.gl-px-0.gl-pt-4{ class: ('js-task-list-container' if can?(current_user, :admin_milestone, milestone)), data: { lock_version: @milestone.lock_version } }
= markdown_field(milestone, :description)
+ -# This textarea is necessary for `task_list.js` to work.
+ %textarea.hidden.js-task-list-field{ data: { value: milestone.description, update_url: milestone_path(milestone, format: :json)} }
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index 2502f7fca62..d56e24a070a 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -2,7 +2,7 @@
- primary = local_assigns.fetch(:primary, false)
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-py-0' }, header_options: { class: milestone_header_class(primary, issuables) }) do |c|
- - c.header do
+ - c.with_header do
.gl-flex-grow-2
= title
.gl-ml-3.gl-flex-shrink-0.gl-font-weight-bold.gl-white-space-nowrap{ class: milestone_counter_class(primary) }
@@ -11,7 +11,7 @@
= sprite_icon('issues', css_class: 'gl-vertical-align-text-bottom')
= number_with_delimiter(issuables.length)
= render_if_exists "shared/milestones/issuables_weight", issuables: issuables
- = c.body do
+ - c.with_body do
- class_prefix = dom_class(issuables).pluralize
%ul{ class: "content-list milestone-#{class_prefix}-list", id: "#{class_prefix}-list-#{id}" }
= render partial: 'shared/milestones/issuable',
diff --git a/app/views/shared/milestones/_milestone_complete_alert.html.haml b/app/views/shared/milestones/_milestone_complete_alert.html.haml
index bde8a0b91b0..dc923465c2f 100644
--- a/app/views/shared/milestones/_milestone_complete_alert.html.haml
+++ b/app/views/shared/milestones/_milestone_complete_alert.html.haml
@@ -4,5 +4,5 @@
= render Pajamas::AlertComponent.new(variant: :success,
alert_options: { data: { testid: 'all-issues-closed-alert' }},
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= yield
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 8c49977fe82..cd1667cb3b3 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -1,8 +1,10 @@
- show_project_name = local_assigns.fetch(:show_project_name, false)
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
= gl_tabs_nav({ class: %w[scrolling-tabs js-milestone-tabs] }) do
= gl_tab_link_to '#tab-issues', item_active: true, data: { endpoint: milestone_tab_path(milestone, 'issues', show_project_name: show_project_name) } do
= _('Issues')
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index 72081856da6..40a71aa53dc 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -2,6 +2,7 @@
= form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do
= hidden_field_tag :target_id, '', class: 'js-form-target-id'
= hidden_field_tag :target_type, '', class: 'js-form-target-type'
+ .flash-container
= render layout: 'shared/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
= render 'shared/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', qa_selector: 'edit_note_field', placeholder: _("Write a comment or drag your files here…")
= render 'shared/notes/hints'
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index eb36de8167c..0fed59aaff3 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -1,7 +1,7 @@
- issuable = @issue || @merge_request
- discussion_locked = issuable&.discussion_locked?
-%ul#notes-list.notes.main-notes-list.timeline
+%ul#notes-list.notes.main-notes-list.timeline{ data: { 'qa_selector': 'notes_list' } }
= render "shared/notes/notes"
= render 'shared/notes/edit_form', project: @project
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 79a33316b1a..e09736cad6c 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -9,7 +9,6 @@
- compact_mode = false unless local_assigns[:compact_mode] == true
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- css_class = "gl-sm-display-flex gl-align-items-center gl-vertical-align-middle!" if project.description.blank? && !show_last_commit_as_description
-- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
- last_pipeline = last_pipeline_from_status_cache(project) if show_pipeline_status_icon
@@ -17,14 +16,13 @@
- css_metadata_classes = "gl-display-flex gl-align-items-center gl-ml-5 gl-reset-color! icon-wrapper has-tooltip"
%li.project-row
- = cache(cache_key) do
- - if avatar
- .project-cell.gl-w-11
- = link_to project_path(project), class: dom_class(project) do
- - if project.creator && use_creator_avatar
- = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5')
- - else
- = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
+ - if avatar
+ .project-cell.gl-w-11
+ = link_to project_path(project), class: dom_class(project) do
+ - if project.creator && use_creator_avatar
+ = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5')
+ - else
+ = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
.project-cell{ class: css_class }
.project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } }
.gl-display-flex.gl-align-items-center.gl-flex-wrap
@@ -83,7 +81,7 @@
= _('Updated')
= updated_tooltip
- .project-cell{ class: "#{css_class} gl-xs-display-none!" }
+ .project-cell{ class: "#{css_class} gl-display-none! gl-sm-display-table-cell!" }
.project-controls.gl-display-flex.gl-flex-direction-column.gl-align-items-flex-end.gl-w-full{ data: { testid: 'project_controls'} }
.controls.gl-display-flex.gl-align-items-center.gl-mb-2{ class: "#{css_controls_class} gl-pr-0!" }
- if show_pipeline_status_icon && last_pipeline.present?
diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml
index 12246d1dcfa..c4524125a21 100644
--- a/app/views/shared/projects/_topics.html.haml
+++ b/app/views/shared/projects/_topics.html.haml
@@ -5,7 +5,7 @@
%span.gl-p-2.gl-text-gray-500
= _('Topics') + ':'
- project.topics_to_show.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - explore_project_topic_path = topic_explore_projects_cleaned_path(topic_name: topic[:name])
- if topic[:title].length > max_project_topic_length
%a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
= gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
@@ -18,7 +18,7 @@
- content = capture do
%span.gl-display-inline-flex.gl-flex-wrap
- project.topics_not_shown.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - explore_project_topic_path = topic_explore_projects_cleaned_path(topic_name: topic[:name])
- if topic[:title].length > max_project_topic_length
%a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
= gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml
index 57ac1370f8d..24071ed0da4 100644
--- a/app/views/shared/promotions/_promote_servicedesk.html.haml
+++ b/app/views/shared/promotions/_promote_servicedesk.html.haml
@@ -2,7 +2,7 @@
close_options: {'aria-label' => s_('Promotions|Dismiss Service Desk promotion'), class: 'js-close-callout'},
svg_path: 'illustrations/service_desk_callout.svg',
button_text: s_('Promotions|Configure Service Desk'), button_link: help_page_path('user/project/service_desk.html', anchor: 'configuring-service-desk')) do |c|
- - c.title do
+ - c.with_title do
= _('Improve customer support with Service Desk')
%p
= _('Service Desk allows people to create issues in your GitLab instance without their own user account. It provides a unique email address for end users to create issues in a project. Replies can be sent either through the GitLab interface or by email. End users only see threads through email.')
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index f4b6c3c3a50..216aaad443f 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -42,12 +42,12 @@
- if local_assigns[:in_gitlab_com_admin_context]
.form-group.row
= label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
- = _('Public projects Minutes cost factor')
+ = _('Public projects compute cost factor')
.col-sm-10
= f.text_field :public_projects_minutes_cost_factor, class: 'form-control'
.form-group.row
= label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
- = _('Private projects Minutes cost factor')
+ = _('Private projects compute cost factor')
.col-sm-10
= f.text_field :private_projects_minutes_cost_factor, class: 'form-control'
.form-actions
diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml
index 686cd1a081b..30e5587c413 100644
--- a/app/views/shared/runners/_runner_details.html.haml
+++ b/app/views/shared/runners/_runner_details.html.haml
@@ -1,4 +1,4 @@
-%h1.page-title.gl-font-size-h-display
+%h1.page-title.gl-font-size-h-display.gl-display-flex.gl-align-items-center
= s_('Runners|Runner #%{runner_id}') % { runner_id: runner.id }
= render 'shared/runners/runner_type_badge', runner: runner
diff --git a/app/views/shared/runners/_runner_type_alert.html.haml b/app/views/shared/runners/_runner_type_alert.html.haml
index 63ecdaf4149..26c3501e3d9 100644
--- a/app/views/shared/runners/_runner_type_alert.html.haml
+++ b/app/views/shared/runners/_runner_type_alert.html.haml
@@ -4,13 +4,13 @@
= render Pajamas::AlertComponent.new(alert_options: alert_options,
title: s_('Runners|This runner is available to all projects and subgroups in a group.'),
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= s_('Runners|Use Group runners when you want all projects in a group to have access to a set of runners.')
= link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'group-runners'), target: '_blank', rel: 'noopener noreferrer'
- else
= render Pajamas::AlertComponent.new(alert_options: alert_options,
title: s_('Runners|This runner is associated with specific projects.'),
dismissible: false) do |c|
- = c.body do
+ - c.with_body do
= s_('Runners|You can set up a project runner to be used by multiple projects but you cannot make this a shared or group runner.')
= link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'project-runners'), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/shared/runners/_runner_type_badge.html.haml b/app/views/shared/runners/_runner_type_badge.html.haml
index a8a93f3dd76..9930f60b755 100644
--- a/app/views/shared/runners/_runner_type_badge.html.haml
+++ b/app/views/shared/runners/_runner_type_badge.html.haml
@@ -1,7 +1,7 @@
-
-- if runner.instance_type?
- = gl_badge_tag s_('Runners|shared'), variant: :success
-- elsif runner.group_type?
- = gl_badge_tag s_('Runners|group'), variant: :success
-- else
- = gl_badge_tag s_('Runners|project'), variant: :info
+.gl-ml-2
+ - if runner.instance_type?
+ = gl_badge_tag s_('Runners|shared'), variant: :success
+ - elsif runner.group_type?
+ = gl_badge_tag s_('Runners|group'), variant: :success
+ - else
+ = gl_badge_tag s_('Runners|project'), variant: :info
diff --git a/app/views/shared/topics/_topic.html.haml b/app/views/shared/topics/_topic.html.haml
index 9b9630733fd..de2682d9d40 100644
--- a/app/views/shared/topics/_topic.html.haml
+++ b/app/views/shared/topics/_topic.html.haml
@@ -1,10 +1,10 @@
- max_topic_title_length = 30
-- detail_page_link = topic_explore_projects_path(topic_name: topic.name)
+- detail_page_link = topic_explore_projects_cleaned_path(topic_name: topic.name)
.col-lg-3.col-md-4.col-sm-12
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' },
body_options: { class: 'gl-display-flex gl-align-items-center' }) do |c|
- = c.body do
+ - c.with_body do
= link_to detail_page_link do
= render Pajamas::AvatarComponent.new(topic, size: 48, alt: '', class: 'gl-mr-3')
= link_to detail_page_link do
diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml
index 51eb24f6d4a..e3c1ca4d9cf 100644
--- a/app/views/shared/users/_user.html.haml
+++ b/app/views/shared/users/_user.html.haml
@@ -2,7 +2,7 @@
.col-lg-3.col-md-4.col-sm-12
= render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }) do |c|
- = c.body do
+ - c.with_body do
= render Pajamas::AvatarComponent.new(user, size: 48, alt: "", class: 'gl-float-left gl-mr-3')
.user-info
diff --git a/app/views/shared/web_hooks/_hook_errors.html.haml b/app/views/shared/web_hooks/_hook_errors.html.haml
index 098cc19c435..0e5f6d844cd 100644
--- a/app/views/shared/web_hooks/_hook_errors.html.haml
+++ b/app/views/shared/web_hooks/_hook_errors.html.haml
@@ -8,12 +8,12 @@
root_namespace: hook.parent.root_namespace.path }
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook rate limit has been reached'),
variant: :danger) do |c|
- = c.body do
+ - c.with_body do
= s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute.").html_safe % placeholders
- elsif hook.permanently_disabled?
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'),
variant: :danger) do |c|
- = c.body do
+ - c.with_body do
= s_('Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % { strong_start: strong_start, strong_end: strong_end }
- elsif hook.temporarily_disabled?
- help_path = help_page_path('user/project/integrations/webhooks', anchor: 'webhook-fails-or-multiple-webhook-requests-are-triggered')
@@ -24,5 +24,5 @@
help_link_end: link_end }
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook fails to connect'),
variant: :warning) do |c|
- = c.body do
+ - c.with_body do
= s_('Webhooks|The webhook %{help_link_start}failed to connect%{help_link_end}, and will retry in %{retry_time}. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below.').html_safe % placeholders
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
index 868633143cd..8a81e697a59 100644
--- a/app/views/shared/web_hooks/_index.html.haml
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -1,9 +1,9 @@
%hr
= render Pajamas::CardComponent.new(card_options: { id: 'webhooks-index' }, body_options: { class: 'gl-py-0'}) do |c|
- - c.header do
+ - c.with_header do
= hook_class.underscore.humanize.titleize.pluralize
(#{hooks.size})
- - c.body do
+ - c.with_body do
- if hooks.any?
%ul.content-list
- hooks.each do |hook|
diff --git a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
index f8e2dc3d8dd..cbbb2f51fd5 100644
--- a/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
+++ b/app/views/shared/web_hooks/_web_hook_disabled_alert.html.haml
@@ -5,9 +5,9 @@
title: s_('Webhooks|Webhook disabled'),
alert_options: { class: 'gl-my-4 js-web-hook-disabled-callout',
data: { feature_id: Users::CalloutsHelper::WEB_HOOK_DISABLED, dismiss_endpoint: project_callouts_path, project_id: @project.id, defer_links: 'true'} }) do |c|
- = c.body do
+ - c.with_body do
= s_('Webhooks|A webhook in this project was automatically disabled after being retried multiple times.')
= succeed '.' do
= link_to _('Learn more'), help_page_path('user/project/integrations/webhooks', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer'
- = c.actions do
+ - c.with_actions do
= link_to s_('Webhooks|Go to webhooks'), project_hooks_path(@project, anchor: 'webhooks-index'), class: 'btn gl-alert-action btn-confirm gl-button'
diff --git a/app/views/shared/wikis/empty.html.haml b/app/views/shared/wikis/empty.html.haml
index d30a37aaa3e..e83cad0d42f 100644
--- a/app/views/shared/wikis/empty.html.haml
+++ b/app/views/shared/wikis/empty.html.haml
@@ -6,7 +6,7 @@
= render Pajamas::AlertComponent.new(alert_options: { id: 'error_explanation', class: 'gl-mb-3'},
dismissible: false,
variant: :danger) do |c|
- = c.body do
+ - c.with_body do
%ul.gl-pl-4
= @error
diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index c916b6c3d45..7c50031598c 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -6,4 +6,4 @@
= s_('UserProfile|User ID: %{id}') % { id: @user.id }
= clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id)
= render 'middle_dot_divider', stacking: true do
- = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
+ = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 1ebf02ffd39..4113a276416 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -131,8 +131,10 @@
- if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user)
.scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] }
- .fade-left= sprite_icon('chevron-lg-left', size: 12)
- .fade-right= sprite_icon('chevron-lg-right', size: 12)
+ %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') }
+ = sprite_icon('chevron-lg-left', size: 12)
+ %button.fade-right{ type: 'button', title: _('Scroll right'), 'aria-label': _('Scroll right') }
+ = sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.user-profile-nav.scrolling-tabs.nav.nav-tabs.gl-border-b-0
- if profile_tab?(:overview)
%li.js-overview-tab
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 17cc7bb73c2..f8aa06943ee 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -417,6 +417,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: cronjob:database_monitor_locked_tables
+ :worker_name: Database::MonitorLockedTablesWorker
+ :feature_category: :cell
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:database_partition_management
:worker_name: Database::PartitionManagementWorker
:feature_category: :database
@@ -545,7 +554,7 @@
:tags: []
- :name: cronjob:member_invitation_reminder_emails
:worker_name: MemberInvitationReminderEmailsWorker
- :feature_category: :subgroups
+ :feature_category: :groups_and_projects
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -588,6 +597,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: cronjob:object_storage_delete_stale_direct_uploads
+ :worker_name: ObjectStorage::DeleteStaleDirectUploadsWorker
+ :feature_category: :build_artifacts
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: cronjob:packages_cleanup_delete_orphaned_dependencies
:worker_name: Packages::Cleanup::DeleteOrphanedDependenciesWorker
:feature_category: :package_registry
@@ -1245,33 +1263,6 @@
:weight: 1
:idempotent: false
:tags: []
-- :name: github_importer:github_import_import_pull_request_merged_by
- :worker_name: Gitlab::GithubImport::ImportPullRequestMergedByWorker
- :feature_category: :importers
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :cpu
- :weight: 1
- :idempotent: false
- :tags: []
-- :name: github_importer:github_import_import_pull_request_review
- :worker_name: Gitlab::GithubImport::ImportPullRequestReviewWorker
- :feature_category: :importers
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :cpu
- :weight: 1
- :idempotent: false
- :tags: []
-- :name: github_importer:github_import_import_release_attachments
- :worker_name: Gitlab::GithubImport::ImportReleaseAttachmentsWorker
- :feature_category: :importers
- :has_external_dependencies: true
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 1
- :idempotent: false
- :tags: []
- :name: github_importer:github_import_pull_requests_import_merged_by
:worker_name: Gitlab::GithubImport::PullRequests::ImportMergedByWorker
:feature_category: :importers
@@ -1794,6 +1785,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: package_repositories:packages_npm_create_metadata_cache
+ :worker_name: Packages::Npm::CreateMetadataCacheWorker
+ :feature_category: :package_registry
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: package_repositories:packages_npm_deprecate_package
:worker_name: Packages::Npm::DeprecatePackageWorker
:feature_category: :package_registry
@@ -2633,7 +2633,7 @@
:tags: []
- :name: disallow_two_factor_for_group
:worker_name: DisallowTwoFactorForGroupWorker
- :feature_category: :subgroups
+ :feature_category: :groups_and_projects
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2642,7 +2642,7 @@
:tags: []
- :name: disallow_two_factor_for_subgroups
:worker_name: DisallowTwoFactorForSubgroupsWorker
- :feature_category: :subgroups
+ :feature_category: :groups_and_projects
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2714,7 +2714,7 @@
:tags: []
- :name: file_hook
:worker_name: FileHookWorker
- :feature_category: :integrations
+ :feature_category: :webhooks
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -2777,7 +2777,7 @@
:tags: []
- :name: group_destroy
:worker_name: GroupDestroyWorker
- :feature_category: :subgroups
+ :feature_category: :groups_and_projects
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -3027,6 +3027,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: merge_requests_mergeability_check_batch
+ :worker_name: MergeRequests::MergeabilityCheckBatchWorker
+ :feature_category: :code_review_workflow
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: merge_requests_resolve_todos
:worker_name: MergeRequests::ResolveTodosWorker
:feature_category: :code_review_workflow
@@ -3380,7 +3389,7 @@
:tags: []
- :name: projects_record_target_platforms
:worker_name: Projects::RecordTargetPlatformsWorker
- :feature_category: :projects
+ :feature_category: :groups_and_projects
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -3632,7 +3641,7 @@
:tags: []
- :name: web_hook
:worker_name: WebHookWorker
- :feature_category: :integrations
+ :feature_category: :webhooks
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
@@ -3641,7 +3650,7 @@
:tags: []
- :name: web_hooks_log_destroy
:worker_name: WebHooks::LogDestroyWorker
- :feature_category: :integrations
+ :feature_category: :webhooks
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
@@ -3650,7 +3659,7 @@
:tags: []
- :name: web_hooks_log_execution
:worker_name: WebHooks::LogExecutionWorker
- :feature_category: :integrations
+ :feature_category: :webhooks
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb
index 2735498b6bb..0b2c96e7ace 100644
--- a/app/workers/ci/cancel_pipeline_worker.rb
+++ b/app/workers/ci/cancel_pipeline_worker.rb
@@ -14,12 +14,14 @@ module Ci
def perform(pipeline_id, auto_canceled_by_pipeline_id)
::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
- pipeline.cancel_running(
- # cascade_to_children is false because we iterate through children
- # we also cancel bridges prior to prevent more children
+ # cascade_to_children is false because we iterate through children
+ # we also cancel bridges prior to prevent more children
+ ::Ci::CancelPipelineService.new(
+ pipeline: pipeline,
+ current_user: nil,
cascade_to_children: false,
auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id
- )
+ ).force_execute
end
end
end
diff --git a/app/workers/ci/update_locked_unknown_artifacts_worker.rb b/app/workers/ci/update_locked_unknown_artifacts_worker.rb
index 2d37ebb3c93..c796b7a28a8 100644
--- a/app/workers/ci/update_locked_unknown_artifacts_worker.rb
+++ b/app/workers/ci/update_locked_unknown_artifacts_worker.rb
@@ -15,8 +15,6 @@ module Ci
feature_category :build_artifacts
def perform
- return unless ::Feature.enabled?(:ci_job_artifacts_backlog_work)
-
artifact_counts = Ci::JobArtifacts::UpdateUnknownLockedStatusService.new.execute
log_extra_metadata_on_done(:removed_count, artifact_counts[:removed])
diff --git a/app/workers/clusters/integrations/check_prometheus_health_worker.rb b/app/workers/clusters/integrations/check_prometheus_health_worker.rb
index 0c0d86e975c..b65b3424c3a 100644
--- a/app/workers/clusters/integrations/check_prometheus_health_worker.rb
+++ b/app/workers/clusters/integrations/check_prometheus_health_worker.rb
@@ -18,15 +18,7 @@ module Clusters
idempotent!
worker_has_external_dependencies!
- def perform
- demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys
-
- clusters = Clusters::Cluster.with_integration_prometheus
- .with_project_http_integrations(demo_project_ids)
-
- # Move to a seperate worker with scoped context if expanded to do work on customer projects
- clusters.each { |cluster| Clusters::Integrations::PrometheusHealthCheckService.new(cluster).execute }
- end
+ def perform; end
end
end
end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index 408354d5caa..6cb9bd34969 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -38,8 +38,12 @@ module Gitlab
# client - An instance of `Gitlab::GithubImport::Client`
# hash - A Hash containing the details of the object to import.
def import(project, client, hash)
- if project.import_state&.canceled?
- info(project.id, message: 'project import canceled')
+ unless project.import_state&.in_progress?
+ info(
+ project.id,
+ message: 'Project import is no longer running. Stopping worker.',
+ import_status: project.import_state.status
+ )
return
end
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
index 1feaaf917b2..a5287fcfbe2 100644
--- a/app/workers/concerns/gitlab/github_import/stage_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -9,8 +9,12 @@ module Gitlab
return unless (project = find_project(project_id))
- if project.import_state&.canceled?
- info(project_id, message: 'project import canceled')
+ unless project.import_state&.in_progress?
+ info(
+ project_id,
+ message: 'Project import is no longer running. Stopping worker.',
+ import_status: project.import_state.status
+ )
return
end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 1674ed1483a..c260e06607c 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -33,6 +33,8 @@ module WorkerAttributes
security_scans: 2
}.stringify_keys.freeze
+ DEFAULT_DEFER_DELAY = 5.seconds
+
class_methods do
def feature_category(value, *extras)
set_class_attribute(:feature_category, value)
@@ -190,5 +192,20 @@ module WorkerAttributes
def big_payload?
!!get_class_attribute(:big_payload)
end
+
+ def defer_on_database_health_signal(gitlab_schema, delay_by = DEFAULT_DEFER_DELAY, tables = [])
+ set_class_attribute(
+ :database_health_check_attrs,
+ { gitlab_schema: gitlab_schema, delay_by: delay_by, tables: tables }
+ )
+ end
+
+ def defer_on_database_health_signal?
+ database_health_check_attrs.present?
+ end
+
+ def database_health_check_attrs
+ get_class_attribute(:database_health_check_attrs)
+ end
end
end
diff --git a/app/workers/container_registry/record_data_repair_detail_worker.rb b/app/workers/container_registry/record_data_repair_detail_worker.rb
index f400568a3ef..390481f8e01 100644
--- a/app/workers/container_registry/record_data_repair_detail_worker.rb
+++ b/app/workers/container_registry/record_data_repair_detail_worker.rb
@@ -14,7 +14,6 @@ module ContainerRegistry
worker_resource_boundary :unknown
idempotent!
- MAX_CAPACITY = 2
LEASE_TIMEOUT = 1.hour.to_i
def perform_work
@@ -60,11 +59,15 @@ module ContainerRegistry
end
def max_running_jobs
- MAX_CAPACITY
+ current_application_settings.container_registry_data_repair_detail_worker_max_concurrency.to_i
end
private
+ def current_application_settings
+ ::Gitlab::CurrentSettings.current_application_settings
+ end
+
def next_project
Project.pending_data_repair_analysis.first
end
diff --git a/app/workers/database/batched_background_migration/execution_worker.rb b/app/workers/database/batched_background_migration/execution_worker.rb
index 53c92ab8969..1bdc829418a 100644
--- a/app/workers/database/batched_background_migration/execution_worker.rb
+++ b/app/workers/database/batched_background_migration/execution_worker.rb
@@ -15,6 +15,7 @@ module Database
included do
data_consistency :always
feature_category :database
+ prefer_calling_context_feature_category true
queue_namespace :batched_background_migrations
end
diff --git a/app/workers/database/batched_background_migration/single_database_worker.rb b/app/workers/database/batched_background_migration/single_database_worker.rb
index b7b46937db2..ebf63d34cbf 100644
--- a/app/workers/database/batched_background_migration/single_database_worker.rb
+++ b/app/workers/database/batched_background_migration/single_database_worker.rb
@@ -16,7 +16,6 @@ module Database
included do
data_consistency :always
feature_category :database
- prefer_calling_context_feature_category true
idempotent!
end
@@ -58,39 +57,19 @@ module Database
Gitlab::Database::SharedModel.using_connection(base_model.connection) do
break unless self.class.enabled?
- if parallel_execution_enabled?
- migrations = Gitlab::Database::BackgroundMigration::BatchedMigration
- .active_migrations_distinct_on_table(connection: base_model.connection, limit: max_running_migrations).to_a
+ migrations = Gitlab::Database::BackgroundMigration::BatchedMigration
+ .active_migrations_distinct_on_table(connection: base_model.connection, limit: max_running_migrations).to_a
- queue_migrations_for_execution(migrations) if migrations.any?
- else
- break unless active_migration
-
- with_exclusive_lease(active_migration.interval) do
- run_active_migration
- end
- end
+ queue_migrations_for_execution(migrations) if migrations.any?
end
end
private
- def parallel_execution_enabled?
- Feature.enabled?(:batched_migrations_parallel_execution)
- end
-
def max_running_migrations
execution_worker_class.max_running_jobs
end
- def active_migration
- @active_migration ||= Gitlab::Database::BackgroundMigration::BatchedMigration.active_migration(connection: base_model.connection)
- end
-
- def run_active_migration
- execution_worker_class.new.perform_work(tracking_database, active_migration.id)
- end
-
def tracking_database
self.class.tracking_database
end
diff --git a/app/workers/database/monitor_locked_tables_worker.rb b/app/workers/database/monitor_locked_tables_worker.rb
new file mode 100644
index 00000000000..66296ea1c0d
--- /dev/null
+++ b/app/workers/database/monitor_locked_tables_worker.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Database
+ class MonitorLockedTablesWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop: disable Scalability/CronWorkerContext
+
+ sidekiq_options retry: false
+ feature_category :cell
+ data_consistency :sticky
+ idempotent!
+
+ version 1
+
+ INITIAL_DATABASE_RESULT = {
+ tables_need_lock: [],
+ tables_need_lock_count: 0,
+ tables_need_unlock: [],
+ tables_need_unlock_count: 0
+ }.freeze
+
+ def perform
+ return unless Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES
+ return if Feature.disabled?(:monitor_database_locked_tables, type: :ops)
+
+ lock_writes_results = ::Gitlab::Database::TablesLocker.new(dry_run: true, include_partitions: false).lock_writes
+
+ tables_lock_info_per_db = ::Gitlab::Database.database_base_models_with_gitlab_shared.keys.to_h do |db_name, _|
+ [db_name, INITIAL_DATABASE_RESULT.deep_dup]
+ end
+
+ lock_writes_results.each do |result|
+ handle_lock_writes_result(tables_lock_info_per_db, result)
+ end
+
+ log_extra_metadata_on_done(:results, tables_lock_info_per_db)
+ end
+
+ private
+
+ def handle_lock_writes_result(results, result)
+ case result[:action]
+ when "needs_lock"
+ results[result[:database]][:tables_need_lock] << result[:table]
+ results[result[:database]][:tables_need_lock_count] += 1
+ when "needs_unlock"
+ results[result[:database]][:tables_need_unlock] << result[:table]
+ results[result[:database]][:tables_need_unlock_count] += 1
+ end
+ end
+ end
+end
diff --git a/app/workers/disallow_two_factor_for_group_worker.rb b/app/workers/disallow_two_factor_for_group_worker.rb
index 5b958f9f31f..1ee2585a718 100644
--- a/app/workers/disallow_two_factor_for_group_worker.rb
+++ b/app/workers/disallow_two_factor_for_group_worker.rb
@@ -8,7 +8,7 @@ class DisallowTwoFactorForGroupWorker
sidekiq_options retry: 3
include ExceptionBacktrace
- feature_category :subgroups
+ feature_category :groups_and_projects
idempotent!
def perform(group_id)
diff --git a/app/workers/disallow_two_factor_for_subgroups_worker.rb b/app/workers/disallow_two_factor_for_subgroups_worker.rb
index 500c13deed2..02dceee2488 100644
--- a/app/workers/disallow_two_factor_for_subgroups_worker.rb
+++ b/app/workers/disallow_two_factor_for_subgroups_worker.rb
@@ -10,7 +10,7 @@ class DisallowTwoFactorForSubgroupsWorker
INTERVAL = 2.seconds.to_i
- feature_category :subgroups
+ feature_category :groups_and_projects
idempotent!
def perform(group_id)
diff --git a/app/workers/file_hook_worker.rb b/app/workers/file_hook_worker.rb
index 77aaf957254..703e0c9add7 100644
--- a/app/workers/file_hook_worker.rb
+++ b/app/workers/file_hook_worker.rb
@@ -5,7 +5,7 @@ class FileHookWorker # rubocop:disable Scalability/IdempotentWorker
data_consistency :always
sidekiq_options retry: false
- feature_category :integrations
+ feature_category :webhooks
loggable_arguments 0
urgency :low
diff --git a/app/workers/gitlab/github_gists_import/import_gist_worker.rb b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
index 8cbbe35dd30..1f17c98dff9 100644
--- a/app/workers/gitlab/github_gists_import/import_gist_worker.rb
+++ b/app/workers/gitlab/github_gists_import/import_gist_worker.rb
@@ -4,7 +4,6 @@ module Gitlab
module GithubGistsImport
class ImportGistWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- include Gitlab::NotifyUponDeath
GISTS_ERRORS_BY_ID = 'gitlab:github-gists-import:%{user_id}:errors'
@@ -14,40 +13,55 @@ module Gitlab
sidekiq_options dead: false, retry: 5
- sidekiq_retries_exhausted do |msg, _|
- new.track_gist_import('failed', msg['args'][0])
+ sidekiq_retries_exhausted do |msg|
+ args = msg['args']
+ user_id = args[0]
+ gist_hash = args[1]
+ jid = msg['jid']
+
+ new.perform_failure(user_id, gist_hash, msg['error_class'], msg['error_message'], msg['correlation_id'])
+
+ # If a job is being exhausted we still want to notify the
+ # Gitlab::GithubGistsImport::FinishImportWorker to prevent
+ # the entire import from getting stuck
+ if args.length == 3 && (key = args.last) && key.is_a?(String)
+ JobWaiter.notify(key, jid)
+ end
end
def perform(user_id, gist_hash, notify_key)
- gist = ::Gitlab::GithubGistsImport::Representation::Gist.from_json_hash(gist_hash)
+ gist = representation_class.from_json_hash(gist_hash)
+ github_identifiers = gist.github_identifiers
- with_logging(user_id, gist.github_identifiers) do
+ with_logging(user_id, github_identifiers) do
result = importer_class.new(gist, user_id).execute
if result.success?
track_gist_import('success', user_id)
else
- error(user_id, result.errors, gist.github_identifiers)
- track_gist_import('failed', user_id)
+ error(user_id, result.errors, github_identifiers)
+
+ perform_failure(
+ user_id,
+ gist_hash,
+ importer_class::FileCountLimitError.name,
+ importer_class::FILE_COUNT_LIMIT_MESSAGE
+ )
end
JobWaiter.notify(notify_key, jid)
end
rescue StandardError => e
- log_and_track_error(user_id, e, gist.github_identifiers)
+ log_and_track_error(user_id, e, github_identifiers)
raise
end
- def track_gist_import(status, user_id)
- user = User.find(user_id)
+ def perform_failure(user_id, gist_hash, exception_class, exception_message, correlation_id = nil)
+ track_gist_import('failed', user_id)
- Gitlab::Tracking.event(
- self.class.name,
- 'create',
- label: 'github_gist_import',
- user: user,
- status: status
- )
+ github_identifiers = representation_class.from_json_hash(gist_hash).github_identifiers
+
+ persist_failure(user_id, exception_class, exception_message, github_identifiers, correlation_id)
end
private
@@ -56,6 +70,10 @@ module Gitlab
::Gitlab::GithubGistsImport::Importer::GistImporter
end
+ def representation_class
+ ::Gitlab::GithubGistsImport::Representation::Gist
+ end
+
def with_logging(user_id, github_identifiers)
info(user_id, 'start importer', github_identifiers)
@@ -64,6 +82,18 @@ module Gitlab
info(user_id, 'importer finished', github_identifiers)
end
+ def track_gist_import(status, user_id)
+ user = User.find(user_id)
+
+ Gitlab::Tracking.event(
+ self.class.name,
+ 'create',
+ label: 'github_gist_import',
+ user: user,
+ status: status
+ )
+ end
+
def log_and_track_error(user_id, exception, github_identifiers)
error(user_id, exception.message, github_identifiers)
@@ -101,6 +131,17 @@ module Gitlab
::Gitlab::Cache::Import::Caching.hash_add(key, gist_id, error_message)
end
+
+ def persist_failure(user_id, exception_class, exception_message, github_identifiers, correlation_id = nil)
+ ImportFailure.create!(
+ source: importer_class.name,
+ exception_class: exception_class,
+ exception_message: exception_message.truncate(255),
+ correlation_id_value: correlation_id || Labkit::Correlation::CorrelationId.current_or_new_id,
+ user_id: user_id,
+ external_identifiers: github_identifiers
+ )
+ end
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
deleted file mode 100644
index 94472fdf6db..00000000000
--- a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove in 16.1 milestone
-# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
-module Gitlab
- module GithubImport
- class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker
- include ObjectImporter
-
- worker_resource_boundary :cpu
-
- def representation_class
- Gitlab::GithubImport::Representation::PullRequest
- end
-
- def importer_class
- Importer::PullRequests::MergedByImporter
- end
-
- def object_type
- :pull_request_merged_by
- end
- 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
deleted file mode 100644
index 6b7d19010ec..00000000000
--- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove in 16.1 milestone
-# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
-module Gitlab
- module GithubImport
- class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker
- include ObjectImporter
-
- worker_resource_boundary :cpu
-
- def representation_class
- Gitlab::GithubImport::Representation::PullRequestReview
- end
-
- def importer_class
- Importer::PullRequests::ReviewImporter
- end
-
- def object_type
- :pull_request_review
- end
- end
- end
-end
diff --git a/app/workers/gitlab/github_import/import_release_attachments_worker.rb b/app/workers/gitlab/github_import/import_release_attachments_worker.rb
deleted file mode 100644
index 0d3831789bf..00000000000
--- a/app/workers/gitlab/github_import/import_release_attachments_worker.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: remove in 16.1 milestone
-# https://gitlab.com/gitlab-org/gitlab/-/issues/409706
-module Gitlab
- module GithubImport
- class ImportReleaseAttachmentsWorker # rubocop:disable Scalability/IdempotentWorker
- include ObjectImporter
-
- def representation_class
- Representation::NoteText
- end
-
- def importer_class
- Importer::NoteAttachmentsImporter
- end
-
- def object_type
- :release_attachment
- end
- end
- end
-end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
index e7eee0915d5..b2dfded0280 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -16,6 +16,12 @@ module Gitlab
# project - An instance of Project.
def import(client, project)
info(project.id, message: "starting importer", importer: 'Importer::PullRequestsImporter')
+
+ # If a user creates a new merge request while the import is in progress, GitLab can assign an IID
+ # to this merge request that already exists for a GitHub Pull Request.
+ # The workaround is to allocate IIDs before starting the importer.
+ allocate_merge_requests_internal_id!(project, client)
+
waiter = Importer::PullRequestsImporter
.new(project, client)
.execute
@@ -41,6 +47,17 @@ module Gitlab
private
+ def allocate_merge_requests_internal_id!(project, client)
+ return if InternalId.exists?(project: project, usage: :merge_requests) # rubocop: disable CodeReuse/ActiveRecord
+
+ options = { state: 'all', sort: 'number', direction: 'desc', per_page: '1' }
+ last_github_pull_request = client.each_object(:pulls, project.import_source, options).first
+
+ return unless last_github_pull_request
+
+ MergeRequest.track_target_project_iid!(project, last_github_pull_request[:number])
+ end
+
def abort_on_failure
true
end
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index a116944feb9..d8dd0b6d7b3 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -8,7 +8,7 @@ class GroupDestroyWorker
sidekiq_options retry: 3
include ExceptionBacktrace
- feature_category :subgroups
+ feature_category :groups_and_projects
idempotent!
deduplicate :until_executed, ttl: 2.hours
diff --git a/app/workers/incident_management/close_incident_worker.rb b/app/workers/incident_management/close_incident_worker.rb
index 6b3e1c5321b..c820a8a97bf 100644
--- a/app/workers/incident_management/close_incident_worker.rb
+++ b/app/workers/incident_management/close_incident_worker.rb
@@ -14,7 +14,7 @@ module IncidentManagement
worker_has_external_dependencies!
def perform(issue_id)
- incident = Issue.incident.opened.find_by_id(issue_id)
+ incident = Issue.with_issue_type(:incident).opened.find_by_id(issue_id)
return unless incident
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
index a7614db30f6..d36ec2611d5 100644
--- a/app/workers/member_invitation_reminder_emails_worker.rb
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -7,7 +7,7 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :subgroups
+ feature_category :groups_and_projects
urgency :low
def perform
diff --git a/app/workers/merge_requests/mergeability_check_batch_worker.rb b/app/workers/merge_requests/mergeability_check_batch_worker.rb
new file mode 100644
index 00000000000..cbe34ac3790
--- /dev/null
+++ b/app/workers/merge_requests/mergeability_check_batch_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeabilityCheckBatchWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+
+ sidekiq_options retry: 3
+
+ feature_category :code_review_workflow
+ idempotent!
+
+ def logger
+ @logger ||= Sidekiq.logger
+ end
+
+ def perform(merge_request_ids)
+ merge_requests = MergeRequest.id_in(merge_request_ids)
+
+ merge_requests.each do |merge_request|
+ result = merge_request.check_mergeability
+
+ next unless result&.error?
+
+ logger.error(
+ worker: self.class.name,
+ message: "Failed to check mergeability of merge request: #{result.message}",
+ merge_request_id: merge_request.id
+ )
+ end
+ end
+ end
+end
diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
index 5c117486da2..5b34f85606d 100644
--- a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
+++ b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb
@@ -16,12 +16,7 @@ module Metrics
idempotent! # in the scope of 24 hours
- def perform
- stale_annotations = ::Metrics::Dashboard::Annotation.ending_before(DEFAULT_CUT_OFF_PERIOD.ago.beginning_of_day)
- stale_annotations.delete_with_limit(DELETE_LIMIT)
-
- self.class.perform_async if stale_annotations.exists?
- end
+ def perform; end
end
end
end
diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
index 62cf35a669f..fe002ffa4a0 100644
--- a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
+++ b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb
@@ -16,11 +16,7 @@ module Metrics
idempotent! # PruneOldAnnotationsWorker worker is idempotent in the scope of 24 hours
- def perform
- # Process is split into two jobs to avoid long running jobs, which are more prone to be disrupted
- # mid work, which may cause some data not be delete, especially because cronjobs has retry option disabled
- PruneOldAnnotationsWorker.perform_async
- end
+ def perform; end
end
end
end
diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
index 63ca27d9c44..668542e51a5 100644
--- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb
+++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
@@ -13,14 +13,7 @@ module Metrics
idempotent!
- def perform(project_id)
- project = Project.find(project_id)
- dashboard_paths = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project)
-
- dashboard_paths.each do |dashboard_path|
- ::Gitlab::Metrics::Dashboard::Importer.new(dashboard_path, project).execute
- end
- end
+ def perform(project_id); end
end
end
end
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index 07699a50e36..0e7f11debd2 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -28,5 +28,15 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker
Issues::AfterCreateService
.new(container: issuable.project, current_user: user)
.execute(issuable)
+
+ log_audit_event if user.project_bot?
+ end
+
+ private
+
+ def log_audit_event
+ # defined in EE
end
end
+
+NewIssueWorker.prepend_mod
diff --git a/app/workers/object_storage/delete_stale_direct_uploads_worker.rb b/app/workers/object_storage/delete_stale_direct_uploads_worker.rb
new file mode 100644
index 00000000000..0d4c9e12cb9
--- /dev/null
+++ b/app/workers/object_storage/delete_stale_direct_uploads_worker.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ObjectStorage
+ class DeleteStaleDirectUploadsWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
+ include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ # TODO: Determine proper feature category for this, as object storage is a shared feature.
+ # For now, only build artifacts use this worker.
+ feature_category :build_artifacts
+ idempotent!
+ deduplicate :until_executed
+
+ def perform
+ result = ObjectStorage::DeleteStaleDirectUploadsService.new.execute
+
+ log_extra_metadata_on_done(:total_pending_entries, result[:total_pending_entries])
+ log_extra_metadata_on_done(:total_deleted_stale_entries, result[:total_deleted_stale_entries])
+ log_extra_metadata_on_done(:execution_timeout, result[:execution_timeout])
+ end
+ end
+end
diff --git a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb
index 0b3d3c98742..4ace9a0e42e 100644
--- a/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb
+++ b/app/workers/packages/cleanup/delete_orphaned_dependencies_worker.rb
@@ -20,8 +20,6 @@ module Packages
REDIS_EXPIRATION_TIME = 2.hours.to_i
def perform
- return unless enabled?
-
start_time
dependency_id = last_processed_dependency_id
@@ -44,10 +42,6 @@ module Packages
private
- def enabled?
- Feature.enabled?(:packages_delete_orphaned_dependencies_worker)
- end
-
def start_time
@start_time ||= ::Gitlab::Metrics::System.monotonic_time
end
diff --git a/app/workers/packages/debian/process_package_file_worker.rb b/app/workers/packages/debian/process_package_file_worker.rb
index 8e7f0b3b987..0e21e98d182 100644
--- a/app/workers/packages/debian/process_package_file_worker.rb
+++ b/app/workers/packages/debian/process_package_file_worker.rb
@@ -19,7 +19,7 @@ module Packages
@distribution_name = distribution_name
@component_name = component_name
- return unless package_file && distribution_name && component_name
+ return unless package_file
# return if file has already been processed
return unless package_file.debian_file_metadatum&.unknown?
diff --git a/app/workers/packages/npm/create_metadata_cache_worker.rb b/app/workers/packages/npm/create_metadata_cache_worker.rb
new file mode 100644
index 00000000000..0b6e34b13eb
--- /dev/null
+++ b/app/workers/packages/npm/create_metadata_cache_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Packages
+ module Npm
+ class CreateMetadataCacheWorker
+ include ApplicationWorker
+
+ data_consistency :sticky
+
+ queue_namespace :package_repositories
+ feature_category :package_registry
+
+ deduplicate :until_executing
+ idempotent!
+
+ def perform(project_id, package_name)
+ project = Project.find_by_id(project_id)
+
+ return unless project && Feature.enabled?(:npm_metadata_cache, project)
+
+ ::Packages::Npm::CreateMetadataCacheService
+ .new(project, package_name)
+ .execute
+ rescue StandardError => e
+ Gitlab::ErrorTracking.log_exception(e, project_id: project_id, package_name: package_name)
+ end
+ end
+ end
+end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 676a834d79d..4971dc3775f 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -36,6 +36,8 @@ class PostReceive
process_project_changes(post_received, container)
elsif repo_type.snippet?
process_snippet_changes(post_received, container)
+ elsif repo_type.design?
+ process_design_management_repository_changes(post_received, container)
else
# Other repos don't have hooks for now
end
@@ -89,10 +91,23 @@ class PostReceive
Snippets::UpdateStatisticsService.new(snippet).execute
end
+ def process_design_management_repository_changes(post_received, design_management_repository)
+ user = identify_user(post_received)
+
+ return false unless user
+
+ replicate_design_management_repository_changes(design_management_repository)
+ expire_caches(post_received, design_management_repository.repository)
+ end
+
def replicate_snippet_changes(snippet)
# Used by Gitlab Geo
end
+ def replicate_design_management_repository_changes(design_management_repository)
+ # Used by GitLab Geo
+ end
+
# Expire the repository status, branch, and tag cache once per push.
def expire_caches(post_received, repository)
repository.expire_status_cache if repository.empty?
diff --git a/app/workers/projects/record_target_platforms_worker.rb b/app/workers/projects/record_target_platforms_worker.rb
index 2e523ccc992..9ebc52f77d3 100644
--- a/app/workers/projects/record_target_platforms_worker.rb
+++ b/app/workers/projects/record_target_platforms_worker.rb
@@ -8,7 +8,7 @@ module Projects
LEASE_TIMEOUT = 1.hour.to_i
APPLE_PLATFORM_LANGUAGES = %w(swift objective-c).freeze
- feature_category :projects
+ feature_category :groups_and_projects
data_consistency :always
deduplicate :until_executed
urgency :low
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 301f3720991..043a16e3527 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -5,7 +5,7 @@
class WebHookWorker
include ApplicationWorker
- feature_category :integrations
+ feature_category :webhooks
loggable_arguments 2, 3
data_consistency :delayed
sidekiq_options retry: 4, dead: false
diff --git a/app/workers/web_hooks/log_destroy_worker.rb b/app/workers/web_hooks/log_destroy_worker.rb
index 9ea5c70e416..d678b5536e7 100644
--- a/app/workers/web_hooks/log_destroy_worker.rb
+++ b/app/workers/web_hooks/log_destroy_worker.rb
@@ -7,7 +7,7 @@ module WebHooks
DestroyError = Class.new(StandardError)
data_consistency :always
- feature_category :integrations
+ feature_category :webhooks
urgency :low
idempotent!
diff --git a/app/workers/web_hooks/log_execution_worker.rb b/app/workers/web_hooks/log_execution_worker.rb
index 280d987fa77..443cb6c0855 100644
--- a/app/workers/web_hooks/log_execution_worker.rb
+++ b/app/workers/web_hooks/log_execution_worker.rb
@@ -5,7 +5,7 @@ module WebHooks
include ApplicationWorker
data_consistency :always
- feature_category :integrations
+ feature_category :webhooks
urgency :low
sidekiq_options retry: 3
loggable_arguments 0, 2, 3