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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-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
709 files changed, 15195 insertions, 6992 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);
- }
-}