summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_canceled.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_created.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_failed.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_manual.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_not_found.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_pending.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_preparing.icobin34494 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_running.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_scheduled.icobin5430 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_skipped.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_success.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_warning.icobin4286 -> 0 bytes
-rw-r--r--app/assets/images/mr_favicons/favicon_status_merged.pngbin0 -> 326 bytes
-rw-r--r--app/assets/javascripts/access_level/constants.js20
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue132
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/token.vue28
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/actions.js28
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/index.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/state.js1
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue148
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue66
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue54
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue4
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js42
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js5
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue38
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue34
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue16
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue24
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue77
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue3
-rw-r--r--app/assets/javascripts/alert_management/constants.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js3
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/bundle.js1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue10
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/utils.js28
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue1
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js22
-rw-r--r--app/assets/javascripts/api/projects_api.js9
-rw-r--r--app/assets/javascripts/api/user_api.js22
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue182
-rw-r--r--app/assets/javascripts/authentication/password/components/password_input.vue82
-rw-r--r--app/assets/javascripts/authentication/password/constants.js8
-rw-r--r--app/assets/javascripts/authentication/password/index.js32
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue8
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js2
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue23
-rw-r--r--app/assets/javascripts/batch_comments/index.js4
-rw-r--r--app/assets/javascripts/behaviors/date_picker.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js23
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js6
-rw-r--r--app/assets/javascripts/blob/sketch_viewer.js3
-rw-r--r--app/assets/javascripts/blob/template_selector.js3
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js5
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue39
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue141
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue1
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue38
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql7
-rw-r--r--app/assets/javascripts/build_artifacts.js3
-rw-r--r--app/assets/javascripts/ci/artifacts/components/app.vue (renamed from app/assets/javascripts/artifacts/components/app.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue (renamed from app/assets/javascripts/artifacts/components/artifact_delete_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_row.vue (renamed from app/assets/javascripts/artifacts/components/artifact_row.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue65
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue (renamed from app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue73
-rw-r--r--app/assets/javascripts/ci/artifacts/components/feedback_banner.vue (renamed from app/assets/javascripts/artifacts/components/feedback_banner.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue (renamed from app/assets/javascripts/artifacts/components/job_artifacts_table.vue)106
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_checkbox.vue (renamed from app/assets/javascripts/artifacts/components/job_checkbox.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/constants.js (renamed from app/assets/javascripts/artifacts/constants.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/cache_update.js (renamed from app/assets/javascripts/artifacts/graphql/cache_update.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql (renamed from app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql (renamed from app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/index.js (renamed from app/assets/javascripts/artifacts/index.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/utils.js (renamed from app/assets/javascripts/artifacts/utils.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue97
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue17
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue6
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue42
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue14
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js16
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue104
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue105
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js70
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue110
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js31
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js17
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue21
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/index.js12
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutations.js10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/state.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue7
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue14
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue44
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue31
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/utils.js38
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_create_form.vue35
-rw-r--r--app/assets/javascripts/ci/runner/constants.js2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql5
-rw-r--r--app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue98
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/index.js33
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue19
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js2
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue2
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue42
-rw-r--r--app/assets/javascripts/comment_templates/components/app.vue (renamed from app/assets/javascripts/saved_replies/components/app.vue)4
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue (renamed from app/assets/javascripts/saved_replies/components/form.vue)40
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue (renamed from app/assets/javascripts/saved_replies/components/list.vue)6
-rw-r--r--app/assets/javascripts/comment_templates/components/list_item.vue116
-rw-r--r--app/assets/javascripts/comment_templates/index.js (renamed from app/assets/javascripts/saved_replies/index.js)4
-rw-r--r--app/assets/javascripts/comment_templates/pages/edit.vue (renamed from app/assets/javascripts/saved_replies/pages/edit.vue)4
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue (renamed from app/assets/javascripts/saved_replies/pages/index.vue)2
-rw-r--r--app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql (renamed from app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql (renamed from app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql (renamed from app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql)0
-rw-r--r--app/assets/javascripts/comment_templates/routes.js (renamed from app/assets/javascripts/saved_replies/routes.js)0
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/constants.js3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue140
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue15
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue168
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue135
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue61
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue109
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue129
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue45
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference_label.vue (renamed from app/assets/javascripts/content_editor/components/wrappers/label.vue)0
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js30
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js50
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js3
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js16
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue22
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue6
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js8
-rw-r--r--app/assets/javascripts/deprecated_notes.js24
-rw-r--r--app/assets/javascripts/design_management/index.js4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue9
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js9
-rw-r--r--app/assets/javascripts/diffs/components/app.vue136
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue32
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality_item.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue40
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue12
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue22
-rw-r--r--app/assets/javascripts/diffs/components/shared/findings_drawer.vue110
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue12
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/i18n.js6
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js110
-rw-r--r--app/assets/javascripts/diffs/store/utils.js6
-rw-r--r--app/assets/javascripts/diffs/utils/merge_request.js13
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue2
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js40
-rw-r--r--app/assets/javascripts/editor/schema/ci.json4
-rw-r--r--app/assets/javascripts/emoji/index.js4
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js3
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue49
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue39
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_pods.vue111
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue16
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue93
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js6
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue6
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue8
-rw-r--r--app/assets/javascripts/environments/graphql/client.js9
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql6
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql27
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js14
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql14
-rw-r--r--app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js56
-rw-r--r--app/assets/javascripts/environments/index.js3
-rw-r--r--app/assets/javascripts/environments/mount_show.js4
-rw-r--r--app/assets/javascripts/error_tracking/utils.js12
-rw-r--r--app/assets/javascripts/featurable/constants.js6
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue4
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js16
-rw-r--r--app/assets/javascripts/filtered_search/droplab/utils.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js12
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js4
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js1
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js43
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json5
-rw-r--r--app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql)4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue1
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue8
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue16
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/groups/index.js2
-rw-r--r--app/assets/javascripts/groups/init_group_readme.js26
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/settings/components/group_settings_readme.vue147
-rw-r--r--app/assets/javascripts/groups/settings/constants.js4
-rw-r--r--app/assets/javascripts/groups/settings/init_group_settings_readme.js24
-rw-r--r--app/assets/javascripts/header.js6
-rw-r--r--app/assets/javascripts/header_search/components/app.vue47
-rw-r--r--app/assets/javascripts/header_search/constants.js2
-rw-r--r--app/assets/javascripts/header_search/init.js36
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue2
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js1
-rw-r--r--app/assets/javascripts/ide/lib/languages/codeowners.js39
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js5
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/import/constants.js28
-rw-r--r--app/assets/javascripts/import/details/components/import_details_app.vue25
-rw-r--r--app/assets/javascripts/import/details/components/import_details_table.vue106
-rw-r--r--app/assets/javascripts/import/details/index.js18
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue66
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue26
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue88
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js4
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue8
-rw-r--r--app/assets/javascripts/incidents/constants.js9
-rw-r--r--app/assets/javascripts/init_diff_stats_dropdown.js10
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_notification.vue20
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue8
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue100
-rw-r--r--app/assets/javascripts/invite_members/constants.js21
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js10
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue7
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue9
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js35
-rw-r--r--app/assets/javascripts/issuable/mixins/related_issuable_mixin.js7
-rw-r--r--app/assets/javascripts/issues/constants.js3
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/issues/index.js7
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue2
-rw-r--r--app/assets/javascripts/issues/list/constants.js4
-rw-r--r--app/assets/javascripts/issues/list/graphql.js14
-rw-r--r--app/assets/javascripts/issues/list/index.js8
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions_item.vue3
-rw-r--r--app/assets/javascripts/issues/new/components/type_select.vue113
-rw-r--r--app/assets/javascripts/issues/new/index.js27
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue25
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue36
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue78
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue17
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue11
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js3
-rw-r--r--app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js97
-rw-r--r--app/assets/javascripts/lib/apollo/local_db.js14
-rw-r--r--app/assets/javascripts/lib/graphql.js64
-rw-r--r--app/assets/javascripts/lib/mermaid.js5
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js38
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js1
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js33
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js45
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js78
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js105
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vuex.js38
-rw-r--r--app/assets/javascripts/lib/utils/web_ide_navigator.js24
-rw-r--r--app/assets/javascripts/locale/ensure_single_line.cjs2
-rw-r--r--app/assets/javascripts/main.js6
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue13
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue23
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestones/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue98
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue27
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js5
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue47
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue16
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js5
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/monitoring/utils.js9
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/actions.js5
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/getters.js1
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/index.js13
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js3
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutations.js7
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js2
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue4
-rw-r--r--app/assets/javascripts/new_branch_form.js5
-rw-r--r--app/assets/javascripts/new_commit_form.js3
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe.vue46
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe_util.js44
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue14
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue119
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue12
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue13
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue2
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js30
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js10
-rw-r--r--app/assets/javascripts/notes/utils.js5
-rw-r--r--app/assets/javascripts/oauth_application/components/oauth_secret.vue106
-rw-r--r--app/assets/javascripts/oauth_application/constants.js20
-rw-r--r--app/assets/javascripts/oauth_application/index.js21
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue15
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue86
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue71
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue31
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue37
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue92
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue96
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue80
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql33
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql38
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue41
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js9
-rw-r--r--app/assets/javascripts/pages/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/applications/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue)0
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue)0
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/constants.js (renamed from app/assets/javascripts/pages/admin/jobs/index/components/constants.js)9
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue118
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js62
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql81
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue19
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js17
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue5
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js6
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-rw-r--r--app/assets/javascripts/pages/import/github/details/index.js3
-rw-r--r--app/assets/javascripts/pages/oauth/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/comment_templates/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/saved_replies/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/artifacts/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js57
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js6
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project.js139
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js12
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue5
-rw-r--r--app/assets/javascripts/pages/time_tracking/timelogs/index.js3
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue105
-rw-r--r--app/assets/javascripts/performance_bar/index.js16
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js9
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js35
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js6
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue152
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue80
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql24
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js5
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js15
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue30
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue27
-rw-r--r--app/assets/javascripts/profile/gl_crop.js17
-rw-r--r--app/assets/javascripts/profile/index.js4
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/commits/index.js16
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue18
-rw-r--r--app/assets/javascripts/projects/new/index.js2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue8
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue11
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue8
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue4
-rw-r--r--app/assets/javascripts/protected_branches/constants.js6
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js63
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue8
-rw-r--r--app/assets/javascripts/ref/stores/actions.js4
-rw-r--r--app/assets/javascripts/ref/stores/index.js8
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js3
-rw-r--r--app/assets/javascripts/ref/stores/state.js1
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue6
-rw-r--r--app/assets/javascripts/releases/components/tag_create.vue91
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue276
-rw-r--r--app/assets/javascripts/releases/components/tag_search.vue121
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/constants.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js20
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js14
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js4
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue15
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue96
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue28
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue11
-rw-r--r--app/assets/javascripts/repository/constants.js2
-rw-r--r--app/assets/javascripts/repository/event_hub.js3
-rw-r--r--app/assets/javascripts/repository/index.js24
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue101
-rw-r--r--app/assets/javascripts/search/index.js1
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue21
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue28
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/data.js (renamed from app/assets/javascripts/search/sidebar/constants/language_filter_data.js)0
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/index.vue (renamed from app/assets/javascripts/search/sidebar/components/language_filter.vue)40
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/tracking.js39
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue4
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue14
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue40
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js4
-rw-r--r--app/assets/javascripts/search/sidebar/utils.js2
-rw-r--r--app/assets/javascripts/search/store/actions.js5
-rw-r--r--app/assets/javascripts/search/store/constants.js14
-rw-r--r--app/assets/javascripts/search/store/getters.js15
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/search/store/utils.js6
-rw-r--r--app/assets/javascripts/search_autocomplete.js520
-rw-r--r--app/assets/javascripts/search_autocomplete_utils.js19
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue9
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js45
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue58
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue27
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js1
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue40
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue69
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue13
-rw-r--r--app/assets/javascripts/sidebar/constants.js2
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js16
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/sidebar/utils.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue59
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue29
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue56
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue379
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue147
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue42
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue79
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js15
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js82
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js6
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/utils.js81
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue98
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue7
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue97
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue104
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue74
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue135
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue91
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue80
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue120
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue66
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js34
-rw-r--r--app/assets/javascripts/super_sidebar/mock_data.js54
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js44
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js21
-rw-r--r--app/assets/javascripts/syntax_highlight.js3
-rw-r--r--app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql69
-rw-r--r--app/assets/javascripts/time_tracking/components/timelog_source_cell.vue50
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_app.vue229
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_table.vue105
-rw-r--r--app/assets/javascripts/time_tracking/index.js32
-rw-r--r--app/assets/javascripts/tracking/constants.js3
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js9
-rw-r--r--app/assets/javascripts/tracking/index.js12
-rw-r--r--app/assets/javascripts/tracking/tracker.js10
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue12
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue1
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue39
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js37
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql1
-rw-r--r--app/assets/javascripts/users_select/index.js11
-rw-r--r--app/assets/javascripts/visibility_level/constants.js32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue97
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js104
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue78
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js13
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue4
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue (renamed from app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue)71
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js99
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue152
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/history_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue4
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue18
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue2
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue4
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue12
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue27
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js2
-rw-r--r--app/assets/javascripts/webhooks/components/test_dropdown.vue45
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue6
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue125
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue138
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue49
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue4
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue172
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue65
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue18
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue113
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue36
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue214
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue71
-rw-r--r--app/assets/javascripts/work_items/constants.js6
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql4
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/zen_mode.js3
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss3
-rw-r--r--app/assets/stylesheets/components/detail_page.scss (renamed from app/assets/stylesheets/pages/detail_page.scss)3
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss8
-rw-r--r--app/assets/stylesheets/components/whats_new.scss14
-rw-r--r--app/assets/stylesheets/framework/blocks.scss8
-rw-r--r--app/assets/stylesheets/framework/calendar.scss53
-rw-r--r--app/assets/stylesheets/framework/common.scss35
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss8
-rw-r--r--app/assets/stylesheets/framework/diffs.scss57
-rw-r--r--app/assets/stylesheets/framework/files.scss26
-rw-r--r--app/assets/stylesheets/framework/filters.scss21
-rw-r--r--app/assets/stylesheets/framework/flash.scss16
-rw-r--r--app/assets/stylesheets/framework/header.scss72
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/layout.scss10
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss3
-rw-r--r--app/assets/stylesheets/framework/mixins.scss20
-rw-r--r--app/assets/stylesheets/framework/page_title.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss65
-rw-r--r--app/assets/stylesheets/framework/sortable.scss9
-rw-r--r--app/assets/stylesheets/framework/source_editor.scss23
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss113
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss55
-rw-r--r--app/assets/stylesheets/framework/tables.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss29
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/design_management.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/issuable.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/issuable_list.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/login.scss (renamed from app/assets/stylesheets/pages/login.scss)3
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss109
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss36
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss36
-rw-r--r--app/assets/stylesheets/page_bundles/projects_usage_quotas.scss (renamed from app/assets/stylesheets/pages/storage_quota.scss)4
-rw-r--r--app/assets/stylesheets/page_bundles/releases.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/search.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/wiki.scss51
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss24
-rw-r--r--app/assets/stylesheets/pages/commits.scss15
-rw-r--r--app/assets/stylesheets/pages/issues.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss22
-rw-r--r--app/assets/stylesheets/pages/notes.scss115
-rw-r--r--app/assets/stylesheets/pages/projects.scss2
-rw-r--r--app/assets/stylesheets/pages/registry.scss2
-rw-r--r--app/assets/stylesheets/pages/settings.scss37
-rw-r--r--app/assets/stylesheets/performance_bar.scss3
-rw-r--r--app/assets/stylesheets/print.scss6
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss115
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss93
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss238
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss16
-rw-r--r--app/assets/stylesheets/utilities.scss196
756 files changed, 13162 insertions, 6551 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico b/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
deleted file mode 100644
index 48b1095370d..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_canceled.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_created.ico b/app/assets/images/ci_favicons/canary/favicon_status_created.ico
deleted file mode 100644
index 623c728faf6..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_created.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico b/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
deleted file mode 100644
index 3073fe5a761..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_failed.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico b/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
deleted file mode 100644
index 6c713d7b675..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_manual.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico b/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
deleted file mode 100644
index dbf855fdafd..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_not_found.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico b/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
deleted file mode 100644
index ccd00606aeb..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_pending.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
deleted file mode 100644
index 6cdf3ae2e36..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_running.ico b/app/assets/images/ci_favicons/canary/favicon_status_running.ico
deleted file mode 100644
index 968e7c4c2d4..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_running.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico b/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico
deleted file mode 100644
index 5444b8e41dc..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_scheduled.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico b/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
deleted file mode 100644
index 7e3be35cc3a..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_skipped.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_success.ico b/app/assets/images/ci_favicons/canary/favicon_status_success.ico
deleted file mode 100644
index a1fb6e91d65..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_success.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico b/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
deleted file mode 100644
index 5d931619fb2..00000000000
--- a/app/assets/images/ci_favicons/canary/favicon_status_warning.ico
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/mr_favicons/favicon_status_merged.png b/app/assets/images/mr_favicons/favicon_status_merged.png
new file mode 100644
index 00000000000..0acb2e463a9
--- /dev/null
+++ b/app/assets/images/mr_favicons/favicon_status_merged.png
Binary files differ
diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js
new file mode 100644
index 00000000000..02a4a3c2f15
--- /dev/null
+++ b/app/assets/javascripts/access_level/constants.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+
+// Matches `lib/gitlab/access.rb`
+export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0;
+export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5;
+export const ACCESS_LEVEL_GUEST_INTEGER = 10;
+export const ACCESS_LEVEL_REPORTER_INTEGER = 20;
+export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30;
+export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40;
+export const ACCESS_LEVEL_OWNER_INTEGER = 50;
+
+export const ACCESS_LEVEL_LABELS = {
+ [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'),
+ [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'),
+ [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'),
+ [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'),
+ [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'),
+ [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'),
+ [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'),
+};
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
index c66b595ffdc..a5f8f369604 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -1,10 +1,15 @@
<script>
-import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
import { createAlert } from '~/alert';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
+import {
+ OPERATORS_IS,
+ TOKEN_TYPE_AUTHOR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
import eventHub from '../event_hub';
import {
findCommitIndex,
@@ -12,6 +17,7 @@ import {
removeIfReadyToBeRemoved,
removeIfPresent,
} from '../utils';
+import Token from './token.vue';
export default {
components: {
@@ -19,9 +25,9 @@ export default {
GlTabs,
GlTab,
ReviewTabContainer,
- GlSearchBoxByType,
GlSprintf,
GlBadge,
+ GlFilteredSearch,
},
props: {
contextCommitsPath: {
@@ -41,6 +47,49 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ availableTokens: [
+ {
+ icon: 'pencil',
+ title: __('Author'),
+ type: TOKEN_TYPE_AUTHOR,
+ operators: OPERATORS_IS,
+ token: UserToken,
+ defaultAuthors: [],
+ unique: true,
+ fetchAuthors: this.fetchAuthors,
+ initialAuthors: [],
+ },
+ {
+ formattedKey: __('Committed-before'),
+ key: 'committed-before',
+ type: 'committed-before-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_before',
+ title: __('Committed-before'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ },
+ {
+ formattedKey: __('Committed-after'),
+ key: 'committed-after',
+ type: 'committed-after-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_after',
+ title: __('Committed-after'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ },
+ ],
+ };
+ },
computed: {
...mapState([
'tabIndex',
@@ -98,8 +147,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('openModal', this.openModal);
- clearTimeout(this.timeout);
- this.timeout = null;
},
methods: {
...mapActions([
@@ -114,10 +161,8 @@ export default {
'setSearchText',
'setToRemoveCommits',
'resetModalState',
+ 'fetchAuthors',
]),
- focusSearch() {
- this.$refs.searchInput.focusInput();
- },
openModal() {
this.searchCommits();
this.fetchContextCommits();
@@ -125,7 +170,6 @@ export default {
},
handleTabChange(tabIndex) {
if (tabIndex === 0) {
- this.focusSearch();
if (this.shouldPurge) {
this.setSelectedCommits(
[...this.commits, ...this.selectedCommits].filter((commit) => commit.isSelected),
@@ -133,17 +177,36 @@ export default {
}
}
},
- handleSearchCommits(value) {
- // We only call the service, if we have 3 characters or we don't have any characters
- if (value.length >= 3) {
- clearTimeout(this.timeout);
- this.timeout = setTimeout(() => {
- this.searchCommits(value);
- }, 500);
- } else if (value.length === 0) {
- this.searchCommits();
+ blurSearchInput() {
+ const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
+ '.gl-filtered-search-token-segment-input',
+ );
+ if (searchInputEl) {
+ searchInputEl.blur();
}
- this.setSearchText(value);
+ },
+ handleSearchCommits(value = []) {
+ const searchValues = value.reduce((acc, searchFilter) => {
+ const isEqualSearch = searchFilter?.value?.operator === '=';
+
+ if (!isEqualSearch && typeof searchFilter === 'object') return acc;
+
+ if (typeof searchFilter === 'string' && searchFilter.length >= 3) {
+ acc.searchText = searchFilter;
+ } else if (searchFilter?.type === 'author' && searchFilter?.value?.data?.length >= 3) {
+ acc.author = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-before-date') {
+ acc.committed_before = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-after-date') {
+ acc.committed_after = searchFilter?.value?.data;
+ }
+
+ return acc;
+ }, {});
+
+ this.searchCommits(searchValues);
+ this.blurSearchInput();
+ this.setSearchText(searchValues.searchText);
},
handleCommitRowSelect(event) {
const index = event[0];
@@ -208,11 +271,12 @@ export default {
},
handleModalClose() {
this.resetModalState();
- clearTimeout(this.timeout);
},
handleModalHide() {
this.resetModalState();
- clearTimeout(this.timeout);
+ },
+ shouldShowInputDateFormat(value) {
+ return ['Committed-before', 'Committed-after'].indexOf(value) !== -1;
},
},
};
@@ -223,13 +287,14 @@ export default {
ref="modal"
cancel-variant="light"
size="md"
+ no-focus-on-show
+ modal-class="add-review-item-modal"
body-class="add-review-item pt-0"
:scrollable="true"
:ok-title="__('Save changes')"
modal-id="add-review-item"
:title="__('Add or remove previously merged commits')"
:ok-disabled="disableSaveButton"
- @shown="focusSearch"
@ok="handleCreateContextCommits"
@cancel="handleModalClose"
@close="handleModalClose"
@@ -245,11 +310,24 @@ export default {
</gl-sprintf>
</template>
<div class="gl-mt-3">
- <gl-search-box-by-type
- ref="searchInput"
- :placeholder="__(`Search by commit title or SHA`)"
- @input="handleSearchCommits"
- />
+ <gl-filtered-search
+ ref="filteredSearchInput"
+ class="flex-grow-1"
+ :placeholder="__(`Search or filter commits`)"
+ :available-tokens="availableTokens"
+ @clear="handleSearchCommits"
+ @submit="handleSearchCommits"
+ >
+ <template #title="{ value }">
+ <div>
+ {{ value }}
+ <span v-if="shouldShowInputDateFormat(value)" class="title-hint-text">
+ &lt;{{ __('yyyy-mm-dd') }}&gt;
+ </span>
+ </div>
+ </template>
+ </gl-filtered-search>
+
<review-tab-container
:is-loading="isLoadingCommits"
:loading-error="commitsLoadingError"
diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue
new file mode 100644
index 00000000000..c403adbbf60
--- /dev/null
+++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ val: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" />
+</template>
diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js
index de9c7488ace..f085b0d0e5e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/actions.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js
@@ -1,8 +1,11 @@
import _ from 'lodash';
+import * as Sentry from '@sentry/browser';
import Api from '~/api';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants';
import * as types from './mutation_types';
export const setBaseConfig = ({ commit }, options) => {
@@ -11,14 +14,14 @@ export const setBaseConfig = ({ commit }, options) => {
export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex);
-export const searchCommits = ({ dispatch, commit, state }, searchText) => {
+export const searchCommits = ({ dispatch, commit, state }, search = {}) => {
commit(types.FETCH_COMMITS);
let params = {};
- if (searchText) {
+ if (search) {
params = {
params: {
- search: searchText,
+ ...search,
per_page: 40,
},
};
@@ -37,7 +40,7 @@ export const searchCommits = ({ dispatch, commit, state }, searchText) => {
}
return c;
});
- if (!searchText) {
+ if (!search) {
dispatch('setCommits', { commits: [...commits, ...state.contextCommits] });
} else {
dispatch('setCommits', { commits });
@@ -131,6 +134,23 @@ export const setSelectedCommits = ({ commit }, selected) => {
commit(types.SET_SELECTED_COMMITS, selectedCommits);
};
+export const fetchAuthors = ({ dispatch, state }, author = null) => {
+ const { projectId } = state;
+ return axios
+ .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), {
+ params: {
+ project_id: projectId,
+ states: ACTIVE_AND_BLOCKED_USER_STATES,
+ search: author,
+ },
+ })
+ .then(({ data }) => data)
+ .catch((error) => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
+};
+
export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText);
export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data);
diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js
index 0bf3441379b..560834a26ae 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
@@ -12,4 +13,5 @@ export default () =>
state: state(),
actions,
mutations,
+ modules: { filters },
});
diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js
index 37239adccbb..fed3148bc9e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/state.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/state.js
@@ -1,4 +1,5 @@
export default () => ({
+ projectId: '',
contextCommitsPath: '',
tabIndex: 0,
isLoadingCommits: false,
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
new file mode 100644
index 00000000000..f2271f8af24
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
@@ -0,0 +1,148 @@
+<script>
+import { GlButton, GlDropdown, GlModal } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+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',
+ },
+ },
+};
+
+export default {
+ name: 'AbuseReportActions',
+ components: {
+ GlButton,
+ GlDropdown,
+ 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: this.blockUser,
+ confirmText: this.$options.i18n.blockUserConfirm,
+ },
+ removeUserAndReport: {
+ action: this.removeUserAndReport,
+ confirmText: this.removeUserAndReportConfirmText,
+ },
+ };
+ },
+ },
+ 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() {
+ window.location.reload();
+ },
+ 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>
+ <gl-dropdown text="Actions" text-sr-only icon="ellipsis_v" category="tertiary" no-caret right>
+ <div class="gl-px-2">
+ <gl-button block variant="danger" @click="showConfirmModal('removeUserAndReport')">
+ {{ $options.i18n.removeUserAndReport }}
+ </gl-button>
+ <gl-button block :disabled="userBlocked" @click="showConfirmModal('block')">
+ {{ blockUserButtonText }}
+ </gl-button>
+ <gl-button block @click="removeReport">
+ {{ $options.i18n.removeReport }}
+ </gl-button>
+ </div>
+ <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"
+ />
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue
new file mode 100644
index 00000000000..f49411604f1
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_details.vue
@@ -0,0 +1,66 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlCollapse } from '@gitlab/ui';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+ components: {
+ GlButton,
+ GlCollapse,
+ },
+ directives: { SafeHtml },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ collapseId: uniqueId('abuse-report-detail-'),
+ };
+ },
+ computed: {
+ toggleText() {
+ return this.isVisible ? __('Hide details') : __('Show details');
+ },
+ reportedUserCreatedAt() {
+ const { reportedUser } = this.report;
+ return sprintf(__('User joined %{timeAgo}'), {
+ timeAgo: getTimeago().format(reportedUser.createdAt),
+ });
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <gl-collapse :id="collapseId" v-model="isVisible">
+ <dl class="gl-mb-2">
+ <dd>{{ reportedUserCreatedAt }}</dd>
+
+ <dt>{{ __('Message') }}</dt>
+ <dd v-safe-html="report.message"></dd>
+ </dl>
+ </gl-collapse>
+ <div>
+ <gl-button
+ :aria-expanded="`${isVisible}`"
+ :aria-controls="collapseId"
+ size="small"
+ variant="link"
+ @click="toggleCollapse"
+ >{{ toggleText }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
index a4211002f71..a9fe59a7b85 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -1,11 +1,20 @@
<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { getTimeago } from '~/lib/utils/datetime_utility';
+import { queryToObject } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { SORT_UPDATED_AT } from '../constants';
+import AbuseReportActions from './abuse_report_actions.vue';
+import AbuseReportDetails from './abuse_report_details.vue';
export default {
name: 'AbuseReportRow',
components: {
+ AbuseReportDetails,
+ GlLink,
+ GlSprintf,
+ AbuseReportActions,
ListItem,
},
props: {
@@ -15,14 +24,31 @@ export default {
},
},
computed: {
- updatedAt() {
- const template = __('Updated %{timeAgo}');
- return sprintf(template, { timeAgo: getTimeago().format(this.report.updatedAt) });
+ displayDate() {
+ const { sort } = queryToObject(window.location.search);
+ const { createdAt, updatedAt } = this.report;
+ const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort)
+ ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt }
+ : { template: __('Created %{timeAgo}'), timeAgo: createdAt };
+
+ return sprintf(template, { timeAgo: getTimeago().format(timeAgo) });
+ },
+ reported() {
+ const { reportedUser } = this.report;
+ return sprintf('%{userLinkStart}%{reported}%{userLinkEnd}', {
+ reported: reportedUser.name,
+ });
+ },
+ reporter() {
+ const { reporter } = this.report;
+ return sprintf('%{reporterLinkStart}%{reporter}%{reporterLinkEnd}', {
+ reporter: reporter.name,
+ });
},
title() {
- const { reportedUser, reporter, category } = this.report;
+ const { category } = this.report;
const template = __('%{reported} reported for %{category} by %{reporter}');
- return sprintf(template, { reported: reportedUser.name, reporter: reporter.name, category });
+ return sprintf(template, { reported: this.reported, reporter: this.reporter, category });
},
},
};
@@ -31,11 +57,25 @@ export default {
<template>
<list-item data-testid="abuse-report-row">
<template #left-primary>
- <div class="gl-font-weight-normal" data-testid="title">{{ title }}</div>
+ <div class="gl-font-weight-normal gl-mb-2" data-testid="title">
+ <gl-sprintf :message="title">
+ <template #userLink="{ content }">
+ <gl-link :href="report.reportedUserPath">{{ content }}</gl-link>
+ </template>
+ <template #reporterLink="{ content }">
+ <gl-link :href="report.reporterPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <abuse-report-details :report="report" />
</template>
<template #right-secondary>
- <div data-testid="updated-at">{{ updatedAt }}</div>
+ <div data-testid="abuse-report-date">{{ displayDate }}</div>
+ <abuse-report-actions :report="report" />
</template>
</list-item>
</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
index b60fe3ae9b8..e1989cadd86 100644
--- a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -8,7 +8,7 @@ import {
SORT_OPTIONS,
isValidSortKey,
} from '~/admin/abuse_reports/constants';
-import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils';
+import { buildFilteredSearchCategoryToken, isValidStatus } from '~/admin/abuse_reports/utils';
export default {
name: 'AbuseReportsFilteredSearchBar',
@@ -32,7 +32,7 @@ export default {
// Backend shows open reports by default if status param is not specified.
// To match that behavior, update the current URL to include status=open
// query when no status query is specified on load.
- if (!query.status) {
+ if (!isValidStatus(query.status)) {
query.status = 'open';
updateHistory({ url: setUrlParams(query), replace: true });
}
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
index ee2e9ab2cbf..ee002f269ac 100644
--- a/app/assets/javascripts/admin/abuse_reports/constants.js
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -40,25 +40,24 @@ export const FILTERED_SEARCH_TOKEN_STATUS = {
};
export const DEFAULT_SORT = 'created_at_desc';
-
-export const SORT_OPTIONS = [
- {
- id: 10,
- title: __('Created date'),
- sortDirection: {
- descending: DEFAULT_SORT,
- ascending: 'created_at_asc',
- },
+export const SORT_UPDATED_AT = Object.freeze({
+ id: 20,
+ title: __('Updated date'),
+ sortDirection: {
+ descending: 'updated_at_desc',
+ ascending: 'updated_at_asc',
},
- {
- id: 20,
- title: __('Updated date'),
- sortDirection: {
- descending: 'updated_at_desc',
- ascending: 'updated_at_asc',
- },
+});
+const SORT_CREATED_AT = Object.freeze({
+ id: 10,
+ title: __('Created date'),
+ sortDirection: {
+ descending: DEFAULT_SORT,
+ ascending: 'created_at_asc',
},
-];
+});
+
+export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT];
export const isValidSortKey = (key) =>
SORT_OPTIONS.some(
@@ -79,3 +78,12 @@ 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'),
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
index 84221901089..d30e8fb0ae5 100644
--- a/app/assets/javascripts/admin/abuse_reports/utils.js
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -1,6 +1,9 @@
-import { FILTERED_SEARCH_TOKEN_CATEGORY } from './constants';
+import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants';
export const buildFilteredSearchCategoryToken = (categories) => {
const options = categories.map((c) => ({ value: c, title: c }));
return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options };
};
+
+export const isValidStatus = (status) =>
+ FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status);
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 4482198675d..3168d693234 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -3,6 +3,7 @@ import {
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
@@ -18,7 +19,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { THEMES, TYPES, TYPE_BANNER } from '../constants';
-import MessageFormGroup from './message_form_group.vue';
import DatetimePicker from './datetime_picker.vue';
const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } };
@@ -31,13 +31,13 @@ export default {
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlFormTextarea,
- MessageFormGroup,
},
directives: {
SafeHtml,
@@ -189,7 +189,7 @@ export default {
<div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div>
</gl-broadcast-message>
- <message-form-group :label="$options.i18n.message" label-for="message-textarea">
+ <gl-form-group :label="$options.i18n.message" label-for="message-textarea">
<gl-form-textarea
id="message-textarea"
v-model="message"
@@ -198,23 +198,23 @@ export default {
:placeholder="$options.i18n.messagePlaceholder"
data-testid="message-input"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.type" label-for="type-select">
+ <gl-form-group :label="$options.i18n.type" label-for="type-select">
<gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" />
- </message-form-group>
+ </gl-form-group>
<template v-if="isBanner">
- <message-form-group :label="$options.i18n.theme" label-for="theme-select">
+ <gl-form-group :label="$options.i18n.theme" label-for="theme-select">
<gl-form-select
id="theme-select"
v-model="theme"
:options="$options.messageThemes"
data-testid="theme-select"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
+ <gl-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
<gl-form-checkbox
id="dismissable-checkbox"
v-model="dismissable"
@@ -223,10 +223,10 @@ export default {
>
<span>{{ $options.i18n.dismissableDescription }}</span>
</gl-form-checkbox>
- </message-form-group>
+ </gl-form-group>
</template>
- <message-form-group
+ <gl-form-group
v-if="glFeatures.roleTargetedBroadcastMessages"
:label="$options.i18n.targetRoles"
data-testid="target-roles-checkboxes"
@@ -235,24 +235,24 @@ export default {
<gl-form-text>
{{ $options.i18n.targetRolesDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
+ <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
<gl-form-input id="target-path-input" v-model="targetPath" />
<gl-form-text>
{{ $options.i18n.targetPathDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.startsAt">
+ <gl-form-group :label="$options.i18n.startsAt">
<datetime-picker v-model="startsAt" />
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.endsAt">
+ <gl-form-group :label="$options.i18n.endsAt">
<datetime-picker v-model="endsAt" />
- </message-form-group>
+ </gl-form-group>
- <div class="form-actions gl-my-3">
+ <div class="gl-my-5">
<gl-button
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
deleted file mode 100644
index eec51c0c28b..00000000000
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<script>
-import { GlFormGroup } from '@gitlab/ui';
-
-export default {
- name: 'MessageFormGroup',
- components: {
- GlFormGroup,
- },
- props: {
- label: {
- type: String,
- required: true,
- },
- labelFor: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-form-group
- :label="label"
- :label-for="labelFor"
- label-cols-sm="2"
- label-class="gl-mt-3"
- label-align-sm="right"
- >
- <slot></slot>
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 0099c8da8e6..af09c7618e2 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -15,7 +15,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -52,7 +52,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 52560ebe5b1..2060528c7a0 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -17,7 +17,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -54,7 +54,9 @@ export default {
</script>
<template>
- <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 203d076914f..d7bdceb4798 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -30,7 +30,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -67,7 +67,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index d50b76aaa92..534e1c76b8f 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -18,7 +18,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -53,7 +53,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index ab1069601d2..40911131d6d 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -25,7 +25,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -62,7 +62,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index d4f9ff4e529..83aa78c9f03 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -49,9 +49,11 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 413804c9a3b..24f0cac73f5 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { associationsCount } from '~/api/user_api';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
@@ -9,7 +9,7 @@ export default {
loading: __('Loading'),
},
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
GlLoadingIcon,
},
props: {
@@ -71,13 +71,15 @@ export default {
</script>
<template>
- <gl-dropdown-item :disabled="loading" :aria-busy="loading" @click.capture.native.stop="onClick">
- <div v-if="loading" class="gl-display-flex gl-align-items-center">
- <gl-loading-icon class="gl-mr-3" />
- {{ $options.i18n.loading }}
- </div>
- <span v-else class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item :disabled="loading" :aria-busy="loading" @action="onClick">
+ <template #list-item>
+ <div v-if="loading" class="gl-display-flex gl-align-items-center">
+ <gl-loading-icon class="gl-mr-3" />
+ {{ $options.i18n.loading }}
+ </div>
+ <span v-else class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index 2b9c4acfcb5..7f786991709 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -28,7 +28,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -65,7 +65,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index 42b6fb3bdd4..f84c7594f87 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -11,7 +11,7 @@ const messageHtml = `<p>${s__(
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -48,7 +48,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index f94e128a945..064f05ef8b1 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -42,7 +42,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index c78c260b4fe..039ab3d651e 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -41,7 +41,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index c1fb80959cf..38c7d3f9b90 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -1,10 +1,9 @@
<script>
import {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlTooltipDirective,
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
@@ -17,10 +16,9 @@ import Actions from './actions';
export default {
components: {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
...Actions,
},
directives: {
@@ -63,6 +61,9 @@ export default {
hasEditAction() {
return this.userActions.includes('edit');
},
+ hasEditActionOnly() {
+ return this.hasEditAction === true && this.hasDeleteActions === false;
+ },
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
@@ -93,10 +94,13 @@ export default {
class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2"
:data-testid="`user-actions-${user.id}`"
>
- <div v-if="hasEditAction" class="gl-p-2">
- <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{
- $options.i18n.edit
- }}</gl-button>
+ <div v-if="hasEditAction" class="gl-p-2" :class="{ 'gl-mr-3': hasEditActionOnly }">
+ <gl-button
+ v-if="showButtonLabels"
+ v-bind="editButtonAttrs"
+ :class="{ 'gl-mr-7': hasEditActionOnly }"
+ >{{ $options.i18n.edit }}</gl-button
+ >
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
@@ -107,12 +111,15 @@ export default {
</div>
<div v-if="hasDropdownActions" class="gl-p-2">
- <gl-dropdown
- :text="$options.i18n.userAdministration"
+ <gl-disclosure-dropdown
+ icon="ellipsis_v"
+ category="tertiary"
+ :toggle-text="$options.i18n.userAdministration"
+ text-sr-only
data-testid="dropdown-toggle"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
- left
+ no-caret
>
<template v-for="action in dropdownSafeActions">
<component
@@ -125,28 +132,32 @@ export default {
>
{{ $options.i18n[action] }}
</component>
- <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
- {{ $options.i18n[action] }}
- </gl-dropdown-item>
- </template>
-
- <gl-dropdown-divider v-if="hasDeleteActions" />
-
- <template v-for="action in dropdownDeleteActions">
- <component
- :is="getActionComponent(action)"
- v-if="getActionComponent(action)"
+ <gl-disclosure-dropdown-item
+ v-else-if="isLdapAction(action)"
:key="action"
- :paths="userPaths"
- :username="user.name"
- :user-id="user.id"
- :user-deletion-obstacles="obstaclesForUserDeletion"
- :data-testid="`delete-${action}`"
+ :data-testid="action"
>
{{ $options.i18n[action] }}
- </component>
+ </gl-disclosure-dropdown-item>
</template>
- </gl-dropdown>
+
+ <gl-disclosure-dropdown-group v-if="hasDeleteActions" bordered>
+ <template v-for="action in dropdownDeleteActions">
+ <component
+ :is="getActionComponent(action)"
+ v-if="getActionComponent(action)"
+ :key="action"
+ :paths="userPaths"
+ :username="user.name"
+ :user-id="user.id"
+ :user-deletion-obstacles="obstaclesForUserDeletion"
+ :data-testid="`delete-${action}`"
+ >
+ {{ $options.i18n[action] }}
+ </component>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index e55622d40ba..2d2c598f953 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -135,7 +135,7 @@ export default {
</template>
<template #cell(settings)="{ item: user }">
- <user-actions :user="user" :paths="paths" />
+ <user-actions :user="user" :paths="paths" :show-button-labels="true" />
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 5229d4c9ae2..170bd6895aa 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -12,6 +12,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sortObjectToString } from '~/lib/utils/table_utility';
import { fetchPolicies } from '~/lib/graphql';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
@@ -229,7 +230,7 @@ export default {
},
getIssueMeta({ issue: { iid, state } }) {
return {
- state: state === 'closed' ? `(${this.$options.i18n.closed})` : '',
+ state: state === STATUS_CLOSED ? `(${this.$options.i18n.closed})` : '',
link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid),
};
},
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
index c98d3865621..4ab772e4523 100644
--- a/app/assets/javascripts/alert_management/constants.js
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -37,12 +37,10 @@ export const ALERTS_STATUS_TABS = [
},
];
-/* eslint-disable @gitlab/require-i18n-strings */
-
/**
* Tracks snowplow event when user views alerts list
*/
export const trackAlertListViewsOptions = {
- category: 'Alert Management',
+ category: 'Alert Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'view_alerts_list',
};
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 2733a59f62d..1a586bd1e91 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -142,7 +142,7 @@ export default {
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
v-gl-tooltip
- name="question"
+ name="question-o"
class="gl-text-gray-500"
:title="$options.i18n.fallbackTooltip"
/>
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index e45ea772ddd..4df5ed425a5 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import axios from '~/lib/utils/axios_utils';
export default {
@@ -9,7 +8,7 @@ export default {
return axios.post(endpoint, data, {
headers: {
'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
+ Authorization: `Bearer ${token}`, // eslint-disable-line @gitlab/require-i18n-strings
},
});
},
diff --git a/app/assets/javascripts/analytics/cycle_analytics/bundle.js b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
new file mode 100644
index 00000000000..9fe31620938
--- /dev/null
+++ b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
@@ -0,0 +1 @@
+export { default } from '~/analytics/cycle_analytics';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
index 704b4ce9c8a..365cbeaf6a2 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -107,7 +107,9 @@ export default {
},
showLinkToDashboard() {
return Boolean(
- this.features?.groupLevelAnalyticsDashboard && this.features?.groupAnalyticsDashboardsPage,
+ this.features?.groupLevelAnalyticsDashboard &&
+ this.features?.groupAnalyticsDashboardsPage &&
+ this.groupPath,
);
},
dashboardsPath() {
@@ -129,6 +131,9 @@ export default {
page: this.pagination?.page || null,
};
},
+ filterBarNamespacePath() {
+ return this.groupPath || this.namespace.fullPath;
+ },
},
methods: {
...mapActions([
@@ -168,10 +173,11 @@ export default {
<div>
<h3>{{ $options.i18n.pageTitle }}</h3>
<value-stream-filters
- :group-path="groupPath"
+ :namespace-path="filterBarNamespacePath"
:has-project-filter="false"
:start-date="createdAfter"
:end-date="createdBefore"
+ :group-path="groupPath"
@setDateRange="onSetDateRange"
/>
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
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 54b632968e2..133513d6c21 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -30,7 +30,7 @@ export default {
UrlSync,
},
props: {
- groupPath: {
+ namespacePath: {
type: String,
required: true,
},
@@ -141,7 +141,7 @@ export default {
<div>
<filtered-search-bar
class="gl-flex-grow-1"
- :namespace="groupPath"
+ :namespace="namespacePath"
recent-searches-storage-key="value-stream-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
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 4c7e18f9895..b9d1c4b0fe0 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
@@ -31,6 +31,10 @@ export default {
required: false,
default: true,
},
+ namespacePath: {
+ type: String,
+ required: true,
+ },
groupPath: {
type: String,
required: true,
@@ -69,7 +73,7 @@ export default {
<filter-bar
data-testid="vsa-filter-bar"
class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
- :group-path="groupPath"
+ :namespace-path="namespacePath"
/>
<div
v-if="hasDateRangeFilter || hasProjectFilter"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js
index 9265ff952e0..d7c3804113e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/utils.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js
@@ -1,3 +1,4 @@
+import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils';
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { joinPaths } from '~/lib/utils/url_utility';
@@ -64,28 +65,6 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
/**
- * @typedef {Object} MetricData
- * @property {String} title - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} [unit] - String representing the decimal point value, e.g '1.5'
- *
- * @typedef {Object} TransformedMetricData
- * @property {String} label - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute
- * @property {String} description - String to display for a description
- * @property {String} unit - String representing the decimal point value, e.g '1.5'
- */
-
-const extractFeatures = (gon) => ({
- // licensed feature toggles
- cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
- groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
- // feature flags
- groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage),
-});
-
-/**
* Builds the initial data object for Value Stream Analytics with data loaded from the backend
*
* @param {Object} dataset - dataset object paseed to the frontend via data-* properties
@@ -99,11 +78,10 @@ export const buildCycleAnalyticsInitialData = ({
createdBefore,
namespaceName,
namespaceFullPath,
- gon,
} = {}) => {
return {
projectId: parseInt(projectId, 10),
- groupPath: `groups/${groupPath}`,
+ groupPath,
namespace: {
name: namespaceName,
fullPath: namespaceFullPath,
@@ -111,7 +89,7 @@ export const buildCycleAnalyticsInitialData = ({
createdAfter: new Date(createdAfter),
createdBefore: new Date(createdBefore),
selectedStage: stage ? JSON.parse(stage) : null,
- features: extractFeatures(gon),
+ features: extractVSAFeaturesFromGON(),
};
};
diff --git a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
index 95a6447ebaf..6c79c8af54a 100644
--- a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
@@ -13,6 +13,7 @@ export default {
},
i18n: {
title: __('Related'),
+ // eslint-disable-next-line @gitlab/require-valid-i18n-helpers
linkText: __('Value Streams Dashboard | DORA'),
},
};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index a85f3fb3730..88a0f6f30cb 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -129,12 +129,28 @@ export const fetchMetricsData = (requests = [], requestPath, params) => {
* @param {Array} projectPaths - Array of project paths to include in the `query` parameter
* @returns a URL or blank string if there is no groupPath set
*/
-export const generateValueStreamsDashboardLink = (groupPath, projectPaths = []) => {
- if (groupPath.length) {
+export const generateValueStreamsDashboardLink = (namespacePath, projectPaths = []) => {
+ if (namespacePath.length) {
const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : '';
const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard';
- const segments = [gon.relative_url_root || '', '/', groupPath, dashboardsSlug];
+ const segments = [gon.relative_url_root || '', '/', namespacePath, dashboardsSlug];
return joinPaths(...segments).concat(query);
}
return '';
};
+
+/**
+ * Extracts the relevant feature and license flags needed for VSA
+ *
+ * @param {Object} gon the global `window.gon` object populated when the page loads
+ * @returns an object containing the extracted feature flags and their boolean status
+ */
+export const extractVSAFeaturesFromGON = () => ({
+ // licensed feature toggles
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+ cycleAnalyticsForProjects: Boolean(gon?.licensed_features?.cycleAnalyticsForProjects),
+ groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
+ // feature flags
+ groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage),
+ vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity),
+});
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 5c0d101ef5b..c72a913aacd 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -21,7 +21,7 @@ export function getProjects(query, options, callback = () => {}) {
defaults.membership = true;
}
- if (gon.features.fullPathProjectSearch && query?.includes('/')) {
+ if (query?.includes('/')) {
defaults.search_namespaces = true;
}
@@ -35,6 +35,13 @@ export function getProjects(query, options, callback = () => {}) {
});
}
+export function createProject(projectData) {
+ const url = buildApiUrl(PROJECTS_PATH);
+ return axios.post(url, projectData).then(({ data }) => {
+ return data;
+ });
+}
+
export function importProjectMembers(sourceId, targetId) {
const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
.replace(':id', sourceId)
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index bcb0f079d3d..3ebb07807d2 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,6 +1,4 @@
import { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/alert';
-import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
@@ -44,22 +42,12 @@ export function getUserStatus(id, options) {
});
}
-export function getUserProjects(userId, query, options, callback) {
+export function getUserProjects(userId, options) {
const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId);
- const defaults = {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- };
- return axios
- .get(url, {
- params: { ...defaults, ...options },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('Something went wrong while fetching projects'),
- }),
- );
+
+ return axios.get(url, {
+ params: options,
+ });
}
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
deleted file mode 100644
index cc08551fdb7..00000000000
--- a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
+++ /dev/null
@@ -1,182 +0,0 @@
-<script>
-import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/alert';
-import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
-import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
-import { removeArtifactFromStore } from '../graphql/cache_update';
-import {
- I18N_BULK_DELETE_BANNER,
- I18N_BULK_DELETE_CLEAR_SELECTION,
- I18N_BULK_DELETE_DELETE_SELECTED,
- I18N_BULK_DELETE_MODAL_TITLE,
- I18N_BULK_DELETE_BODY,
- I18N_BULK_DELETE_ACTION,
- I18N_BULK_DELETE_PARTIAL_ERROR,
- I18N_BULK_DELETE_ERROR,
- I18N_MODAL_CANCEL,
- BULK_DELETE_MODAL_ID,
-} from '../constants';
-
-export default {
- name: 'ArtifactsBulkDelete',
- components: {
- GlButton,
- GlModal,
- GlSprintf,
- },
- inject: ['projectId'],
- props: {
- selectedArtifacts: {
- type: Array,
- required: true,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- isModalVisible: false,
- isDeleting: false,
- };
- },
- computed: {
- checkedCount() {
- return this.selectedArtifacts.length || 0;
- },
- modalActionPrimary() {
- return {
- text: I18N_BULK_DELETE_ACTION(this.checkedCount),
- attributes: {
- loading: this.isDeleting,
- variant: 'danger',
- },
- };
- },
- modalActionCancel() {
- return {
- text: I18N_MODAL_CANCEL,
- attributes: {
- loading: this.isDeleting,
- },
- };
- },
- },
- methods: {
- async onConfirmDelete(e) {
- // don't close modal until deletion is complete
- if (e) {
- e.preventDefault();
- }
- this.isDeleting = true;
-
- try {
- await this.$apollo.mutate({
- mutation: bulkDestroyJobArtifactsMutation,
- variables: {
- projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
- ids: this.selectedArtifacts,
- },
- update: (store, { data }) => {
- const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
- if (errors?.length) {
- createAlert({
- message: I18N_BULK_DELETE_PARTIAL_ERROR,
- captureError: true,
- error: new Error(errors.join(' ')),
- });
- }
- if (destroyedIds?.length) {
- this.$emit('deleted', destroyedCount);
-
- // Remove deleted artifacts from the cache
- destroyedIds.forEach((id) => {
- removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
- });
- store.gc();
-
- this.$emit('clearSelectedArtifacts');
- }
- },
- });
- } catch (error) {
- this.onError(error);
- } finally {
- this.isDeleting = false;
- this.isModalVisible = false;
- }
- },
- onError(error) {
- createAlert({
- message: I18N_BULK_DELETE_ERROR,
- captureError: true,
- error,
- });
- },
- handleClearSelection() {
- this.$emit('clearSelectedArtifacts');
- },
- handleModalShow() {
- this.isModalVisible = true;
- },
- handleModalHide() {
- this.isModalVisible = false;
- },
- },
- i18n: {
- banner: I18N_BULK_DELETE_BANNER,
- clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
- deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
- modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
- modalBody: I18N_BULK_DELETE_BODY,
- },
- BULK_DELETE_MODAL_ID,
-};
-</script>
-<template>
- <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
- <div class="gl-display-flex gl-align-items-center">
- <div>
- <gl-sprintf :message="$options.i18n.banner(checkedCount)">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <div class="gl-ml-auto">
- <gl-button
- variant="default"
- data-testid="bulk-delete-clear-button"
- @click="handleClearSelection"
- >
- {{ $options.i18n.clearSelection }}
- </gl-button>
- <gl-button
- variant="danger"
- data-testid="bulk-delete-delete-button"
- @click="handleModalShow"
- >
- {{ $options.i18n.deleteSelected }}
- </gl-button>
- </div>
- </div>
- <gl-modal
- size="sm"
- :modal-id="$options.BULK_DELETE_MODAL_ID"
- :visible="isModalVisible"
- :title="$options.i18n.modalTitle(checkedCount)"
- :action-primary="modalActionPrimary"
- :action-cancel="modalActionCancel"
- @hide="handleModalHide"
- @primary="onConfirmDelete"
- >
- <gl-sprintf
- data-testid="bulk-delete-modal-content"
- :message="$options.i18n.modalBody(checkedCount)"
- />
- </gl-modal>
- </div>
-</template>
diff --git a/app/assets/javascripts/authentication/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue
new file mode 100644
index 00000000000..7808620cca1
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/components/password_input.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import { SHOW_PASSWORD, HIDE_PASSWORD, PASSWORD_TITLE } from '../constants';
+
+export default {
+ name: 'PasswordInput',
+ i18n: {
+ showPassword: SHOW_PASSWORD,
+ hidePassword: HIDE_PASSWORD,
+ },
+ components: {
+ GlFormInput,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ resourceName: {
+ type: String,
+ required: true,
+ },
+ minimumPasswordLength: {
+ type: String,
+ required: true,
+ },
+ qaSelector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMasked: true,
+ };
+ },
+ computed: {
+ passwordTitle() {
+ return sprintf(PASSWORD_TITLE, { minimum_password_length: this.minimumPasswordLength });
+ },
+ type() {
+ return this.isMasked ? 'password' : 'text';
+ },
+ toggleVisibilityLabel() {
+ return this.isMasked ? this.$options.i18n.showPassword : this.$options.i18n.hidePassword;
+ },
+ toggleVisibilityIcon() {
+ return this.isMasked ? 'eye' : 'eye-slash';
+ },
+ },
+ methods: {
+ handleToggleVisibilityButtonClick() {
+ this.isMasked = !this.isMasked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-field-error-anchor input-icon-wrapper">
+ <gl-form-input
+ :id="`${resourceName}_password`"
+ class="js-password-complexity-validation gl-pr-8!"
+ required
+ autocomplete="new-password"
+ :name="`${resourceName}[password]`"
+ :minlength="minimumPasswordLength"
+ :data-qa-selector="qaSelector"
+ :title="passwordTitle"
+ :type="type"
+ />
+ <gl-button
+ v-gl-tooltip="toggleVisibilityLabel"
+ class="input-icon-right gl-right-0!"
+ category="tertiary"
+ :aria-label="toggleVisibilityLabel"
+ :icon="toggleVisibilityIcon"
+ @click="handleToggleVisibilityButtonClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/password/constants.js b/app/assets/javascripts/authentication/password/constants.js
new file mode 100644
index 00000000000..97e1a882d9d
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/constants.js
@@ -0,0 +1,8 @@
+import { __, s__ } from '~/locale';
+
+export const SHOW_PASSWORD = __('Show password');
+export const HIDE_PASSWORD = __('Hide password');
+
+export const PASSWORD_TITLE = s__(
+ 'SignUp|Minimum length is %{minimum_password_length} characters.',
+);
diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js
new file mode 100644
index 00000000000..36e3b74263c
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import GlFieldErrors from '~/gl_field_errors';
+import PasswordInput from './components/password_input.vue';
+
+export const initTogglePasswordVisibility = () => {
+ const el = document.querySelector('.js-password');
+
+ if (!el) {
+ return null;
+ }
+
+ const { form } = el;
+ const { resourceName, minimumPasswordLength, qaSelector } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'PasswordInputRoot',
+ render(createElement) {
+ return createElement(PasswordInput, {
+ props: {
+ resourceName,
+ minimumPasswordLength,
+ qaSelector,
+ },
+ });
+ },
+ });
+
+ // Since we replaced password input, we need to re-initialize the field errors handler
+ return new GlFieldErrors(form);
+};
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
index 98ed2a31730..907b68e6ffc 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
@@ -7,7 +7,6 @@ export const i18n = {
currentPassword: __('Current password'),
confirmTitle: __('Are you sure?'),
confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'),
- confirm: __('This will invalidate your registered applications and WebAuthn devices.'),
disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
@@ -41,7 +40,6 @@ export default {
GlModal,
},
inject: [
- 'webauthnEnabled',
'isCurrentPasswordRequired',
'profileTwoFactorAuthPath',
'profileTwoFactorAuthMethod',
@@ -59,11 +57,7 @@ export default {
},
computed: {
confirmText() {
- if (this.webauthnEnabled) {
- return i18n.confirmWebAuthn;
- }
-
- return i18n.confirm;
+ return i18n.confirmWebAuthn;
},
},
methods: {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js
index 7d21c19ac4c..cec80335ba0 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/index.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/index.js
@@ -13,7 +13,6 @@ export const initManageTwoFactorForm = () => {
}
const {
- webauthnEnabled = false,
currentPasswordRequired,
profileTwoFactorAuthPath = '',
profileTwoFactorAuthMethod = '',
@@ -26,7 +25,6 @@ export const initManageTwoFactorForm = () => {
return new Vue({
el,
provide: {
- webauthnEnabled,
isCurrentPasswordRequired,
profileTwoFactorAuthPath,
profileTwoFactorAuthMethod,
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index beda251aa1e..107796a31e0 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,19 +1,10 @@
<script>
-import {
- GlDropdown,
- GlButton,
- GlIcon,
- GlForm,
- GlFormGroup,
- GlLink,
- GlFormCheckbox,
-} from '@gitlab/ui';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { createAlert } from '~/alert';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
-import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -22,7 +13,6 @@ export default {
GlIcon,
GlForm,
GlFormGroup,
- GlLink,
GlFormCheckbox,
MarkdownField,
ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
@@ -102,9 +92,6 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
- helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
- anchor: 'submit-a-review',
- }),
};
</script>
@@ -126,14 +113,6 @@ export default {
<gl-form-group label-for="review-note-body" label-class="gl-mb-2">
<template #label>
{{ __('Summary comment (optional)') }}
- <gl-link
- :href="$options.helpPagePath"
- :aria-label="__('More information')"
- target="_blank"
- class="gl-ml-2"
- >
- <gl-icon name="question-o" />
- </gl-link>
</template>
<div class="common-note-form gfm-form">
<div
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 2a8786134cc..f6e9bfd6690 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -7,6 +7,8 @@ import store from '~/mr_notes/stores';
export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
+ if (!el) return;
+
Vue.use(VueApollo);
// eslint-disable-next-line no-new
@@ -18,7 +20,7 @@ export const initReviewBar = () => {
ReviewBar: () => import('./components/review_bar.vue'),
},
provide: {
- newSavedRepliesPath: el.dataset.savedRepliesNewPath,
+ newCommentTemplatePath: el.dataset.newCommentTemplatePath,
},
computed: {
...mapGetters('batchComments', ['draftsCount']),
diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js
index 11fe01ca48d..89f1ad9c89e 100644
--- a/app/assets/javascripts/behaviors/date_picker.js
+++ b/app/assets/javascripts/behaviors/date_picker.js
@@ -9,7 +9,7 @@ export default function initDatePickers() {
const calendar = new Pikaday({
field: $datePicker.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index b1bf6ebcb13..b2348cf0bad 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -97,7 +97,7 @@ class SafeMathRenderer {
<button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button>
</div>
</div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <button type="button" class="close js-close" aria-label="Close">
${spriteIcon('close', 's16')}
</button>
</div>
@@ -184,17 +184,24 @@ class SafeMathRenderer {
attachEvents() {
document.body.addEventListener('click', (event) => {
- if (!event.target.classList.contains('js-lazy-render-math')) {
+ const alert = event.target.closest('.js-lazy-render-math-container');
+
+ if (!alert) {
return;
}
- const parent = event.target.closest('.js-lazy-render-math-container');
-
- const pre = parent.nextElementSibling;
-
- parent.remove();
+ // Handle alert close
+ if (event.target.closest('.js-close')) {
+ alert.remove();
+ return;
+ }
- this.renderElement(pre);
+ // Handle "render anyway"
+ if (event.target.classList.contains('js-lazy-render-math')) {
+ const pre = alert.nextElementSibling;
+ alert.remove();
+ this.renderElement(pre);
+ }
});
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 6a7ce4f1c41..301dd1c5669 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -204,7 +204,11 @@ export default class Shortcuts {
}
static focusSearch(e) {
- $('#search').focus();
+ if (gon.use_new_navigation) {
+ document.querySelector('#super-sidebar-search')?.click();
+ } else {
+ document.querySelector('#search')?.focus();
+ }
if (e.preventDefault) {
e.preventDefault();
diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js
index 2c1c6339fdb..834aa3e5354 100644
--- a/app/assets/javascripts/blob/sketch_viewer.js
+++ b/app/assets/javascripts/blob/sketch_viewer.js
@@ -1,8 +1,7 @@
-/* eslint-disable no-new */
import SketchLoader from './sketch';
export default () => {
const el = document.getElementById('js-sketch-viewer');
- new SketchLoader(el);
+ new SketchLoader(el); // eslint-disable-line no-new
};
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 7eb699eacbe..59b7f82c10e 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,5 +1,3 @@
-/* eslint-disable class-methods-use-this */
-
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -70,6 +68,7 @@ export default class TemplateSelector {
return this.requestFile(item);
}
+ // eslint-disable-next-line class-methods-use-this
requestFile() {
// This `requestFile` method is an abstract method that should
// be added by all subclasses.
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 01d35a0980f..7e667409556 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { createAlert } from '~/alert';
@@ -54,6 +52,7 @@ export default () => {
import('./edit_blob')
.then(({ default: EditBlob } = {}) => {
+ // eslint-disable-next-line no-new
new EditBlob({
assetsPath: `${urlRoot}${assetsPath}`,
filePath,
@@ -80,7 +79,7 @@ export default () => {
window.onbeforeunload = null;
});
- new NewCommitForm(editBlobForm);
+ new NewCommitForm(editBlobForm); // eslint-disable-line no-new
// returning here blocks page navigation
window.onbeforeunload = () => '';
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 48dfcf81f1e..c7e6cb38d15 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -4,6 +4,7 @@ import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export default {
components: {
@@ -11,7 +12,7 @@ export default {
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['initialBoardId', 'initialFilterParams'],
+ inject: ['initialBoardId', 'initialFilterParams', 'isIssueBoard', 'isApolloBoard'],
data() {
return {
boardId: this.initialBoardId,
@@ -19,11 +20,31 @@ export default {
isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
};
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
+
computed: {
...mapGetters(['isSidebarOpen']),
isSwimlanesOn() {
return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false;
},
+ isAnySidebarOpen() {
+ if (this.isApolloBoard) {
+ return this.activeBoardItem?.id;
+ }
+ return this.isSidebarOpen;
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -45,7 +66,7 @@ export default {
</script>
<template>
- <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
+ <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }">
<board-top-bar
:board-id="boardId"
:is-swimlanes-on="isSwimlanesOn"
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3071c1f334e..18495f285da 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,6 +1,8 @@
<script>
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import BoardCardInner from './board_card_inner.vue';
export default {
@@ -9,7 +11,7 @@ export default {
BoardCardInner,
},
mixins: [Tracking.mixin()],
- inject: ['disabled', 'isApolloBoard'],
+ inject: ['disabled', 'isIssueBoard', 'isApolloBoard'],
props: {
list: {
type: Object,
@@ -37,14 +39,30 @@ export default {
default: true,
},
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
...mapState(['selectedBoardItems', 'activeId']),
+ activeItemId() {
+ return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId;
+ },
isActive() {
- return this.item.id === this.activeId;
+ return this.item.id === this.activeItemId;
},
multiSelectVisible() {
return (
- !this.activeId &&
+ !this.activeItemId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
);
},
@@ -83,10 +101,23 @@ export default {
if (isMultiSelect && gon?.features?.boardMultiSelect) {
this.toggleBoardItemMultiSelection(this.item);
} else {
- this.toggleBoardItem({ boardItem: this.item });
+ if (this.isApolloBoard) {
+ this.toggleItem();
+ } else {
+ this.toggleBoardItem({ boardItem: this.item });
+ }
this.track('click_card', { label: 'right_sidebar' });
}
},
+ toggleItem() {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: this.item,
+ isIssue: this.isIssueBoard,
+ },
+ });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 88f51c71e06..befd04c29ae 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -275,7 +275,7 @@ export default {
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
- class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
<work-item-type-icon
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 84a8781db1c..946f3712834 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -206,6 +206,7 @@ export default {
<epics-swimlanes
v-else-if="boardListsToUse.length"
ref="swimlanes"
+ :board-id="boardId"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:filters="filterParams"
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 675878683ab..1b97214ff8b 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,10 +3,12 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import { __, sprintf } from '~/locale';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { ISSUABLE, INCIDENT } from '~/boards/constants';
+import { INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
@@ -16,7 +18,6 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -39,7 +40,6 @@ export default {
SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
multipleAssigneesFeatureAvailable: {
default: false,
@@ -71,31 +71,46 @@ export default {
isGroupBoard: {
default: false,
},
+ isApolloBoard: {
+ default: false,
+ },
},
inheritAttrs: false,
+ apollo: {
+ activeBoardCard: {
+ query: activeBoardItemQuery,
+ variables: {
+ isIssue: true,
+ },
+ update(data) {
+ if (!data.activeBoardItem?.id) {
+ return { id: '', iid: '' };
+ }
+ return {
+ ...data.activeBoardItem,
+ assignees: data.activeBoardItem.assignees?.nodes || [],
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
- ...mapGetters([
- 'isSidebarOpen',
- 'activeBoardItem',
- 'groupPathForActiveIssue',
- 'projectPathForActiveIssue',
- ]),
+ ...mapGetters(['activeBoardItem']),
...mapState(['sidebarType']),
- isIssuableSidebar() {
- return this.sidebarType === ISSUABLE;
+ activeBoardIssuable() {
+ return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem;
},
- isIncidentSidebar() {
- return this.activeBoardItem.type === INCIDENT;
+ isSidebarOpen() {
+ return Boolean(this.activeBoardIssuable?.id);
},
- showSidebar() {
- return this.isIssuableSidebar && this.isSidebarOpen;
+ isIncidentSidebar() {
+ return this.activeBoardIssuable?.type === INCIDENT;
},
sidebarTitle() {
return this.isIncidentSidebar ? __('Incident details') : __('Issue details');
},
- fullPath() {
- return this.activeBoardItem?.referencePath?.split('#')[0] || '';
- },
parentType() {
return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
@@ -120,6 +135,14 @@ export default {
? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue)
: this.labelsFilterBasePath;
},
+ groupPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.lastIndexOf('/'));
+ },
+ projectPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
},
methods: {
...mapActions([
@@ -131,7 +154,19 @@ export default {
'setActiveItemHealthStatus',
]),
handleClose() {
- this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: null,
+ },
+ });
+ } else {
+ this.toggleBoardItem({
+ boardItem: this.activeBoardIssuable,
+ sidebarType: this.sidebarType,
+ });
+ }
},
handleUpdateSelectedLabels({ labels, id }) {
this.setActiveBoardItemLabels({
@@ -143,7 +178,7 @@ export default {
},
handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
- iid: this.activeBoardItem.iid,
+ iid: this.activeBoardIssuable.iid,
projectPath: this.projectPathForActiveIssue,
removeLabelIds: [removeLabelId],
});
@@ -156,7 +191,7 @@ export default {
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
<gl-drawer
v-bind="$attrs"
- :open="showSidebar"
+ :open="isSidebarOpen"
class="boards-sidebar"
variant="sidebar"
@close="handleClose"
@@ -167,25 +202,27 @@ export default {
<template #header>
<sidebar-todo-widget
class="gl-mt-3"
- :issuable-id="activeBoardItem.id"
- :issuable-iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :issuable-id="activeBoardIssuable.id"
+ :issuable-iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
/>
</template>
<template #default>
- <board-sidebar-title data-testid="sidebar-title" />
+ <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" />
<sidebar-assignees-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :initial-assignees="activeBoardItem.assignees"
+ v-if="activeBoardItem.assignees"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
+ :initial-assignees="activeBoardIssuable.assignees"
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
:editable="canUpdate"
- @assignees-updated="setAssignees"
+ @assignees-updated="!isApolloBoard && setAssignees($event)"
/>
<sidebar-dropdown-widget
v-if="epicFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`epic-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="epic"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
@@ -194,7 +231,8 @@ export default {
/>
<div>
<sidebar-dropdown-widget
- :iid="activeBoardItem.iid"
+ :key="`milestone-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="milestone"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="projectPathForActiveIssue"
@@ -203,7 +241,8 @@ export default {
/>
<sidebar-iteration-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`iteration-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
@@ -213,14 +252,14 @@ export default {
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-due-date"
/>
<sidebar-labels-widget
class="block labels"
- :iid="activeBoardItem.iid"
+ :iid="activeBoardIssuable.iid"
:full-path="projectPathForActiveIssue"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
@@ -232,40 +271,40 @@ export default {
workspace-type="project"
:issuable-type="issuableType"
:label-create-type="labelType"
- @onLabelRemove="handleLabelRemove"
- @updateSelectedLabels="handleUpdateSelectedLabels"
+ @onLabelRemove="!isApolloBoard && handleLabelRemove($event)"
+ @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)"
>
{{ __('None') }}
</sidebar-labels-widget>
<sidebar-severity-widget
v-if="isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :project-path="fullPath"
- :initial-severity="activeBoardItem.severity"
+ :iid="activeBoardIssuable.iid"
+ :project-path="projectPathForActiveIssue"
+ :initial-severity="activeBoardIssuable.severity"
/>
<sidebar-weight-widget
v-if="weightFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @weightUpdated="setActiveItemWeight($event)"
+ @weightUpdated="!isApolloBoard && setActiveItemWeight($event)"
/>
<sidebar-health-status-widget
v-if="healthStatusFeatureAvailable"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @statusUpdated="setActiveItemHealthStatus($event)"
+ @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)"
/>
<sidebar-confidentiality-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @confidentialityUpdated="setActiveItemConfidential($event)"
+ @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)"
/>
<sidebar-subscriptions-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-notifications"
/>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 2e14afad963..46612320136 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -22,7 +22,7 @@ import {
TOKEN_TYPE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { AssigneeFilterType } from '~/boards/constants';
+import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants';
import { TYPENAME_ITERATION } from '~/graphql_shared/constants';
import eventHub from '../eventhub';
@@ -33,6 +33,11 @@ export default {
components: { FilteredSearch },
inject: ['initialFilterParams', 'isApolloBoard'],
props: {
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tokens: {
type: Array,
required: true,
@@ -321,6 +326,7 @@ export default {
release_tag: releaseTag,
confidential,
health_status: healthStatus,
+ group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined,
},
(value) => {
if (value || value === false) {
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 9ea801dc9a2..604e71f5993 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -226,12 +226,10 @@ export default {
}
this.cancel();
- if (!this.isApolloBoard) {
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
- }
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
} catch {
this.setError({ message: this.$options.i18n.saveErrorMessage });
} finally {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index a47db661445..5f082066ad4 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sprintf, __ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
@@ -158,10 +159,10 @@ export default {
return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore;
},
epicCreateFormVisible() {
- return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
+ return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm;
},
issueCreateFormVisible() {
- return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
+ return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm;
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
@@ -418,7 +419,6 @@ export default {
v-if="loadingMore"
size="sm"
:label="$options.i18n.loadingMoreboardItems"
- data-testid="count-loading-icon"
/>
<span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index f4358315d45..7dc3e464af0 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -229,9 +229,6 @@ export default {
context: {
isSingleRequest: true,
},
- skip() {
- return this.isEpicBoard;
- },
},
},
created() {
@@ -426,7 +423,7 @@ export default {
<div v-if="list.maxIssueCount !== 0">
•
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ itemsTooltipLabel }}</template>
+ <template #issuesSize>{{ itemsCount }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index fad57758be1..c186346b2ac 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -98,6 +98,7 @@ export default {
<issue-board-filtered-search
v-if="isIssueBoard"
:board="board"
+ :is-swimlanes-on="isSwimlanesOn"
@setFilters="$emit('setFilters', $event)"
/>
<epic-board-filtered-search
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index cdcc7b8e5a6..3c056f296e1 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -52,6 +52,11 @@ export default {
required: false,
default: () => {},
},
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tokensCE() {
@@ -203,6 +208,7 @@ export default {
data-testid="issue-board-filtered-search"
:tokens="tokens"
:board="board"
+ :is-swimlanes-on="isSwimlanesOn"
@setFilters="$emit('setFilters', $event)"
/>
</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index c3f7c7d3ca2..1f28974afd1 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -95,9 +95,12 @@ export default {
class="board-card-info-icon gl-mr-2"
name="calendar"
/>
- <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
- body
- }}</time>
+ <time
+ :class="{ 'text-danger': isPastDue }"
+ datetime="date"
+ class="gl-font-sm board-card-info-text"
+ >{{ body }}</time
+ >
</span>
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index bc12717a92d..611e875fa40 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -38,7 +38,7 @@ export default {
<span>
<span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help">
<gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
- <time class="board-card-info-text">{{ timeEstimate }}</time>
+ <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 43a2b13b81c..020edcb01b8 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { titleQueries } from 'ee_else_ce/boards/constants';
export default {
components: {
@@ -19,6 +20,13 @@ export default {
directives: {
autofocusonshow,
},
+ inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'],
+ props: {
+ activeItem: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
title: '',
@@ -27,7 +35,10 @@ export default {
};
},
computed: {
- ...mapGetters({ item: 'activeBoardItem' }),
+ ...mapGetters(['activeBoardItem']),
+ item() {
+ return this.isApolloBoard ? this.activeItem : this.activeBoardItem;
+ },
pendingChangesStorageKey() {
return this.getPendingChangesKey(this.item);
},
@@ -67,8 +78,9 @@ export default {
},
async setPendingState() {
const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey);
+ const shouldOpen = pendingChanges !== this.title;
- if (pendingChanges) {
+ if (pendingChanges && shouldOpen) {
this.title = pendingChanges;
this.showChangesAlert = true;
await this.$nextTick();
@@ -83,6 +95,26 @@ export default {
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
},
+ async setActiveBoardItemTitle() {
+ if (!this.isApolloBoard) {
+ await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ return;
+ }
+ const { fullPath, issuableType, isEpicBoard, title } = this;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: this.projectPath };
+ await this.$apollo.mutate({
+ mutation: titleQueries[issuableType].mutation,
+ variables: {
+ input: {
+ ...workspacePath,
+ iid: String(this.item.iid),
+ title,
+ },
+ },
+ });
+ },
async setTitle() {
this.$refs.sidebarItem.collapse();
@@ -92,7 +124,7 @@ export default {
try {
this.loading = true;
- await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ await this.setActiveBoardItemTitle();
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index b557dc9205e..d12270e58a4 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -12,6 +12,11 @@ import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
+export const BoardType = {
+ project: 'project',
+ group: 'group',
+};
+
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
new file mode 100644
index 00000000000..81b1b68a038
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+query activeBoardItem {
+ activeBoardItem @client {
+ ...Issue
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
new file mode 100644
index 00000000000..cce558c649e
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+mutation setActiveBoardItem($boardItem: Issue) {
+ setActiveBoardItem(boardItem: $boardItem) @client {
+ ...Issue
+ }
+}
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index e895df01f2c..4d1c4be73a3 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import { hide, initTooltips, show } from '~/tooltips';
import { parseBoolean } from './lib/utils/common_utils';
@@ -24,6 +22,7 @@ export default class BuildArtifacts {
// eslint-disable-next-line class-methods-use-this
setupEntryClick() {
+ // eslint-disable-next-line func-names
return $('.tree-holder').on('click', 'tr[data-link]', function () {
visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink));
});
diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/ci/artifacts/components/app.vue
index 3a07be65341..3a07be65341 100644
--- a/app/assets/javascripts/artifacts/components/app.vue
+++ b/app/assets/javascripts/ci/artifacts/components/app.vue
diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
index 14edd73824e..14edd73824e 100644
--- a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
index f37c4c6f107..f37c4c6f107 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
diff --git a/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
new file mode 100644
index 00000000000..b864fc00bdb
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlSprintf,
+ },
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ },
+};
+</script>
+<template>
+ <div
+ v-if="selectedArtifacts.length > 0"
+ class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"
+ data-testid="bulk-delete-container"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="$options.i18n.banner(checkedCount)">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button
+ variant="default"
+ data-testid="bulk-delete-clear-button"
+ @click="$emit('clearSelectedArtifacts')"
+ >
+ {{ $options.i18n.clearSelection }}
+ </gl-button>
+ <gl-button
+ variant="danger"
+ data-testid="bulk-delete-delete-button"
+ @click="$emit('showBulkDeleteModal')"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
index 7d675251ffd..7d675251ffd 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
new file mode 100644
index 00000000000..00f5b2eab7d
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_MODAL_TITLE,
+ I18N_BULK_DELETE_BODY,
+ I18N_BULK_DELETE_ACTION,
+ I18N_MODAL_CANCEL,
+ BULK_DELETE_MODAL_ID,
+} from '../constants';
+
+export default {
+ name: 'BulkDeleteModal',
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ artifactsToDelete: {
+ type: Array,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.artifactsToDelete.length || 0;
+ },
+ modalActionPrimary() {
+ return {
+ text: I18N_BULK_DELETE_ACTION(this.checkedCount),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: I18N_MODAL_CANCEL,
+ attributes: {
+ disabled: this.isDeleting,
+ },
+ };
+ },
+ },
+ BULK_DELETE_MODAL_ID,
+ i18n: {
+ modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
+ modalBody: I18N_BULK_DELETE_BODY,
+ },
+};
+</script>
+<template>
+ <gl-modal
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :visible="visible"
+ :title="$options.i18n.modalTitle(checkedCount)"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ <gl-sprintf :message="$options.i18n.modalBody(checkedCount)" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
index d2c96b1a201..d2c96b1a201 100644
--- a/app/assets/javascripts/artifacts/components/feedback_banner.vue
+++ b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
index ba4026190a2..a93964eef99 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
@@ -11,12 +11,15 @@ import {
GlFormCheckbox,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+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';
+import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { removeArtifactFromStore } from '../graphql/cache_update';
import {
STATUS_BADGE_VARIANTS,
I18N_DOWNLOAD,
@@ -36,10 +39,13 @@ import {
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,
} from '../constants';
import JobCheckbox from './job_checkbox.vue';
import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
+import BulkDeleteModal from './bulk_delete_modal.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
@@ -67,11 +73,12 @@ export default {
TimeAgo,
JobCheckbox,
ArtifactsBulkDelete,
+ BulkDeleteModal,
ArtifactsTableRowDetails,
FeedbackBanner,
},
mixins: [glFeatureFlagsMixin()],
- inject: ['projectPath', 'canDestroyArtifacts'],
+ inject: ['projectId', 'projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
query: getJobArtifactsQuery,
@@ -106,6 +113,9 @@ export default {
expandedJobs: [],
selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
+ isBulkDeleteModalVisible: false,
+ jobArtifactsToDelete: [],
+ isBulkDeleting: false,
};
},
computed: {
@@ -144,6 +154,12 @@ export default {
canBulkDestroyArtifacts() {
return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
},
+ isDeletingArtifactsForJob() {
+ return this.jobArtifactsToDelete.length > 0;
+ },
+ artifactsToDelete() {
+ return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts;
+ },
},
methods: {
refetchArtifacts() {
@@ -191,12 +207,70 @@ export default {
this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
}
},
+ onConfirmBulkDelete(e) {
+ // don't close modal until deletion is complete
+ if (e) {
+ e.preventDefault();
+ }
+ this.isBulkDeleting = true;
+
+ this.$apollo
+ .mutate({
+ mutation: bulkDestroyJobArtifactsMutation,
+ variables: {
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
+ ids: this.artifactsToDelete,
+ },
+ update: (store, { data }) => {
+ const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
+ if (errors?.length) {
+ createAlert({
+ message: I18N_BULK_DELETE_PARTIAL_ERROR,
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
+ }
+ if (destroyedIds?.length) {
+ this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(destroyedCount));
+
+ // Remove deleted artifacts from the cache
+ destroyedIds.forEach((id) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ });
+ store.gc();
+
+ if (!this.isDeletingArtifactsForJob) {
+ this.clearSelectedArtifacts();
+ }
+ }
+ },
+ })
+ .catch((error) => {
+ this.onError(error);
+ })
+ .finally(() => {
+ this.isBulkDeleting = false;
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ });
+ },
+ onError(error) {
+ createAlert({
+ message: I18N_BULK_DELETE_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ handleBulkDeleteModalShow() {
+ this.isBulkDeleteModalVisible = true;
+ },
+ handleBulkDeleteModalHidden() {
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ },
clearSelectedArtifacts() {
this.selectedArtifacts = [];
},
- showDeletedToast(deletedCount) {
- this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount));
- },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -206,6 +280,13 @@ export default {
browseButtonDisabled(job) {
return !job.browseArtifactsPath;
},
+ deleteButtonDisabled(job) {
+ return !job.hasArtifacts || !this.canBulkDestroyArtifacts;
+ },
+ deleteArtifactsForJob(job) {
+ this.jobArtifactsToDelete = job.artifacts.nodes.map((node) => node.id);
+ this.handleBulkDeleteModalShow();
+ },
},
fields: [
{
@@ -257,11 +338,17 @@ export default {
<div>
<feedback-banner />
<artifacts-bulk-delete
- v-if="canBulkDestroyArtifacts && anyArtifactsSelected"
+ v-if="canBulkDestroyArtifacts"
:selected-artifacts="selectedArtifacts"
- :query-variables="queryVariables"
@clearSelectedArtifacts="clearSelectedArtifacts"
- @deleted="showDeletedToast"
+ @showBulkDeleteModal="handleBulkDeleteModalShow"
+ />
+ <bulk-delete-modal
+ :visible="isBulkDeleteModalVisible"
+ :artifacts-to-delete="artifactsToDelete"
+ :is-deleting="isBulkDeleting"
+ @primary="onConfirmBulkDelete"
+ @hidden="handleBulkDeleteModalHidden"
/>
<gl-table
:items="jobArtifacts"
@@ -382,10 +469,11 @@ export default {
<gl-button
v-if="canDestroyArtifacts"
icon="remove"
+ :disabled="deleteButtonDisabled(item)"
:title="$options.i18n.delete"
:aria-label="$options.i18n.delete"
data-testid="job-artifacts-delete-button"
- disabled
+ @click="deleteArtifactsForJob(item)"
/>
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
index ce49b3f8678..ce49b3f8678 100644
--- a/app/assets/javascripts/artifacts/components/job_checkbox.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js
index 4ac20d963d1..4ac20d963d1 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/ci/artifacts/constants.js
diff --git a/app/assets/javascripts/artifacts/graphql/cache_update.js b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
index 9fa6114c7d4..9fa6114c7d4 100644
--- a/app/assets/javascripts/artifacts/graphql/cache_update.js
+++ b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
index 421b9258ca0..421b9258ca0 100644
--- a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
index 529224b47e6..529224b47e6 100644
--- a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
index 23da65ad0bb..23da65ad0bb 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
index 5737f9f8e8d..5737f9f8e8d 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js
index 6e795fd9bd7..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/ci/artifacts/index.js
diff --git a/app/assets/javascripts/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js
index ebcf0af8d2a..ebcf0af8d2a 100644
--- a/app/assets/javascripts/artifacts/utils.js
+++ b/app/assets/javascripts/ci/artifacts/utils.js
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
index 7387a490177..09b02068388 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,16 +1,25 @@
<script>
-import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { debounce, uniq } from 'lodash';
+import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { __, s__, sprintf } from '~/locale';
import { convertEnvironmentScope } from '../utils';
+import { ENVIRONMENT_QUERY_LIMIT } from '../constants';
export default {
name: 'CiEnvironmentsDropdown',
components: {
+ GlCollapsibleListbox,
GlDropdownDivider,
GlDropdownItem,
- GlCollapsibleListbox,
+ GlSprintf,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
environments: {
type: Array,
required: true,
@@ -33,24 +42,52 @@ export default {
},
filteredEnvironments() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.environments.filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ });
+ },
+ isEnvScopeLimited() {
+ return this.glFeatures?.ciLimitEnvironmentScope;
+ },
+ searchedEnvironments() {
+ // If FF is enabled, search query will be fired so this component will already
+ // receive filtered environments during the refetch.
+ // If FF is disabled, search the existing list of environments in the frontend
+ let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments;
+
+ // If there is no search term, make sure to include *
+ if (this.isEnvScopeLimited && !this.searchTerm) {
+ filtered = uniq([...filtered, '*']);
+ }
- return this.environments
- .filter((environment) => {
- return environment.toLowerCase().includes(lowerCasedSearchTerm);
- })
- .map((environment) => ({
- value: environment,
- text: environment,
- }));
+ return filtered.sort().map((environment) => ({
+ value: environment,
+ text: environment,
+ }));
+ },
+ shouldShowSearchLoading() {
+ return this.areEnvironmentsLoading && this.isEnvScopeLimited;
},
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
},
+ shouldRenderDivider() {
+ return (
+ (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading
+ );
+ },
environmentScopeLabel() {
return convertEnvironmentScope(this.selectedEnvironmentScope);
},
},
methods: {
+ debouncedSearch: debounce(function debouncedSearch(searchTerm) {
+ const newSearchTerm = searchTerm.trim();
+ this.searchTerm = newSearchTerm;
+ if (this.isEnvScopeLimited) {
+ this.$emit('search-environment-scope', newSearchTerm);
+ }
+ }, 500),
selectEnvironment(selected) {
this.$emit('select-environment', selected);
this.selectedEnvironment = selected;
@@ -60,22 +97,46 @@ export default {
this.selectEnvironment(this.searchTerm);
},
},
+ ENVIRONMENT_QUERY_LIMIT,
+ i18n: {
+ maxEnvsNote: s__(
+ 'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.',
+ ),
+ },
};
</script>
<template>
<gl-collapsible-listbox
v-model="selectedEnvironment"
+ block
searchable
- :items="filteredEnvironments"
+ :items="searchedEnvironments"
+ :searching="shouldShowSearchLoading"
:toggle-text="environmentScopeLabel"
- @search="searchTerm = $event.trim()"
+ @search="debouncedSearch"
@select="selectEnvironment"
>
- <template v-if="shouldRenderCreateButton" #footer>
- <gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
- {{ composedCreateButtonLabel }}
- </gl-dropdown-item>
+ <template #footer>
+ <gl-dropdown-divider v-if="shouldRenderDivider" />
+ <div v-if="isEnvScopeLimited" data-testid="max-envs-notice">
+ <gl-dropdown-item class="gl-list-style-none" disabled>
+ <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm">
+ <template #limit>
+ {{ $options.ENVIRONMENT_QUERY_LIMIT }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </div>
+ <div v-if="shouldRenderCreateButton">
+ <!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 -->
+ <gl-dropdown-item
+ class="gl-list-style-none"
+ data-testid="create-wildcard-button"
+ @click="createEnvironmentScope"
+ >
+ {{ composedCreateButtonLabel }}
+ </gl-dropdown-item>
+ </div>
</template>
</gl-collapsible-listbox>
</template>
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 16034cce381..b3ecaceba69 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
@@ -74,6 +74,10 @@ export default {
'maskableRegex',
],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -142,7 +146,11 @@ export default {
isTipVisible() {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
},
- joinedEnvironments() {
+ environmentsList() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return this.environments;
+ }
+
return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
},
maskedFeedback() {
@@ -368,10 +376,12 @@ export default {
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
+ :are-environments-loading="areEnvironmentsLoading"
:selected-environment-scope="variable.environmentScope"
- :environments="joinedEnvironments"
+ :environments="environmentsList"
@select-environment="setEnvironmentScope"
@create-environment-scope="createEnvironmentScope"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
<gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly />
@@ -450,7 +460,7 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3">
+ <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3">
<div>
<p>
<gl-sprintf :message="$options.i18n.awsTipMessage">
@@ -505,7 +515,6 @@ export default {
ref="deleteCiVariable"
variant="danger"
category="secondary"
- data-qa-selector="ci_variable_delete_button"
@click="deleteVarAndClose"
>{{ __('Delete variable') }}</gl-button
>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index 257c3309e10..26e20c690bc 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
@@ -9,6 +9,10 @@ export default {
CiVariableModal,
},
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -100,6 +104,7 @@ export default {
/>
<ci-variable-modal
v-if="showModal"
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
:hide-environment-scope="hideEnvironmentScope"
@@ -110,6 +115,7 @@ export default {
@delete-variable="deleteVariable"
@hideModal="hideModal"
@update-variable="updateVariable"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index 9db9bea63b2..ee2c0a771cf 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
@@ -6,8 +6,10 @@ import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
+ ENVIRONMENT_QUERY_LIMIT,
SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
+ mapMutationActionToToast,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
@@ -162,6 +164,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.environmentQueryVariables,
};
},
update(data) {
@@ -173,10 +176,26 @@ export default {
},
},
computed: {
+ areEnvironmentsLoading() {
+ return this.$apollo.queries.environments.loading;
+ },
+ environmentQueryVariables() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return {
+ first: ENVIRONMENT_QUERY_LIMIT,
+ search: '',
+ };
+ }
+
+ return {};
+ },
isLoading() {
+ // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when
+ // environment query is loading and FF is enabled
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/396990
return (
(this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
- this.$apollo.queries.environments.loading ||
+ this.areEnvironmentsLoading ||
this.isLoadingMoreItems
);
},
@@ -228,6 +247,11 @@ export default {
updateVariable(variable) {
this.variableMutation(UPDATE_MUTATION_ACTION, variable);
},
+ async searchEnvironmentScope(searchTerm) {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ this.$apollo.queries.environments.refetch({ search: searchTerm });
+ }
+ },
async variableMutation(mutationAction, variable) {
try {
const currentMutation = this.mutationData[mutationAction];
@@ -245,11 +269,15 @@ export default {
if (data.ciVariableMutation?.errors?.length) {
const { errors } = data.ciVariableMutation;
createAlert({ message: errors[0] });
- } else if (this.refetchAfterMutation) {
- // The writing to cache for admin variable is not working
- // because there is no ID in the cache at the top level.
- // We therefore need to manually refetch.
- this.$apollo.queries.ciVariables.refetch();
+ } else {
+ this.$toast.show(mapMutationActionToToast[mutationAction](variable.key));
+
+ if (this.refetchAfterMutation) {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.ciVariables.refetch();
+ }
}
} catch (e) {
createAlert({ message: genericMutationErrorText });
@@ -264,6 +292,7 @@ export default {
<template>
<ci-variable-settings
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:entity="entity"
:environments="environments"
@@ -277,6 +306,7 @@ export default {
@handle-prev-page="handlePrevPage"
@handle-next-page="handleNextPage"
@sort-changed="handleSortChanged"
+ @search-environment-scope="searchEnvironmentScope"
@update-variable="updateVariable"
/>
</template>
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 5e367ff33b2..6f6c55e07c7 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
@@ -175,12 +175,7 @@ export default {
v-if="glFeatures.ciVariablesPages"
class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
>
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ valuesButtonText }}</gl-button>
<gl-button
v-gl-modal-directive="$options.modalId"
class="gl-mx-3"
@@ -317,12 +312,7 @@ export default {
@click="setSelectedVariable()"
>{{ __('Add variable') }}</gl-button
>
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</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
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index c77d8c67bc8..c8f67bd3436 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -1,6 +1,7 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+export const ENVIRONMENT_QUERY_LIMIT = 30;
export const SORT_DIRECTIONS = {
ASC: 'KEY_ASC',
@@ -97,6 +98,19 @@ export const ADD_MUTATION_ACTION = 'add';
export const UPDATE_MUTATION_ACTION = 'update';
export const DELETE_MUTATION_ACTION = 'delete';
+export const ADD_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been successfully added.'), { key });
+export const UPDATE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been updated.'), { key });
+export const DELETE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been deleted.'), { key });
+
+export const mapMutationActionToToast = {
+ [ADD_MUTATION_ACTION]: ADD_VARIABLE_TOAST,
+ [UPDATE_MUTATION_ACTION]: UPDATE_VARIABLE_TOAST,
+ [DELETE_MUTATION_ACTION]: DELETE_VARIABLE_TOAST,
+};
+
export const EXPANDED_VARIABLES_NOTE = __(
'%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.',
);
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
index 921e0ca25b9..26d1b6a3aaa 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
@@ -1,7 +1,7 @@
-query getProjectEnvironments($fullPath: ID!) {
+query getProjectEnvironments($fullPath: ID!, $first: Int, $search: String) {
project(fullPath: $fullPath) {
id
- environments {
+ environments(first: $first, search: $search) {
nodes {
id
name
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
index 4775836fcc6..3fe9103c2b3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
@@ -146,7 +146,7 @@ export default {
</gl-sprintf>
</gl-form-checkbox>
</gl-form-group>
- <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
+ <div class="gl-display-flex gl-py-5">
<gl-button
type="submit"
class="js-no-auto-disable gl-mr-3"
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
new file mode 100644
index 00000000000..25bbd6b3180
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { get, toPath } from 'lodash';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlButton,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ formOptions() {
+ return [
+ {
+ key: 'artifacts.paths',
+ title: i18n.ARTIFACTS_AND_CACHE,
+ paths: this.job.artifacts.paths,
+ generateInputDataTestId: (index) => `artifacts-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-paths-button',
+ },
+ {
+ key: 'artifacts.exclude',
+ title: i18n.ARTIFACTS_EXCLUDE_PATHS,
+ paths: this.job.artifacts.exclude,
+ generateInputDataTestId: (index) => `artifacts-exclude-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-exclude-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-exclude-button',
+ },
+ {
+ key: 'cache.paths',
+ title: i18n.CACHE_PATHS,
+ paths: this.job.cache.paths,
+ generateInputDataTestId: (index) => `cache-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-cache-paths-button-${index}`,
+ addButtonDataTestId: 'add-cache-paths-button',
+ },
+ ];
+ },
+ },
+ methods: {
+ deleteStringArrayItem(path) {
+ const parentPath = toPath(path).slice(0, -1);
+ const array = get(this.job, parentPath);
+ if (array.length <= 1) {
+ return;
+ }
+ this.$emit('update-job', path);
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE">
+ <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>
+ </div>
+ <div
+ v-for="(path, index) in entry.paths"
+ :key="index"
+ class="gl-display-flex gl-align-items-center gl-mb-3"
+ >
+ <div class="gl-flex-grow-1 gl-flex-basis-0 gl-mr-3">
+ <gl-form-input
+ class="gl-w-full!"
+ :value="path"
+ :data-testid="entry.generateInputDataTestId(index)"
+ @input="$emit('update-job', `${entry.key}[${index}]`, $event)"
+ />
+ </div>
+ <gl-button
+ category="tertiary"
+ icon="remove"
+ :data-testid="entry.generateDeleteButtonDataTestId(index)"
+ @click="deleteStringArrayItem(`${entry.key}[${index}]`)"
+ />
+ </div>
+ <gl-button
+ category="secondary"
+ variant="confirm"
+ :data-testid="entry.addButtonDataTestId"
+ @click="$emit('update-job', `${entry.key}[${entry.paths.length}]`, '')"
+ >{{ $options.i18n.ADD_PATH }}</gl-button
+ >
+ </div>
+ <gl-form-group :label="$options.i18n.CACHE_KEY">
+ <gl-form-input
+ :value="job.cache.key"
+ data-testid="cache-key-input"
+ @input="$emit('update-job', 'cache.key', $event)"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
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 c2ae7d7be49..c23a0b866d3 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
@@ -20,14 +20,20 @@ export default {
<template>
<gl-accordion-item :title="$options.i18n.IMAGE">
<div class="gl-display-flex">
- <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME">
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ :label="$options.i18n.IMAGE_NAME"
+ >
<gl-form-input
:value="job.image.name"
data-testid="image-name-input"
@input="$emit('update-job', 'image.name', $event)"
/>
</gl-form-group>
- <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT">
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ :label="$options.i18n.IMAGE_ENTRYPOINT"
+ >
<gl-form-input
:value="job.image.entrypoint.join(' ')"
data-testid="image-entrypoint-input"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
index a25b3ca09fd..b49355d539c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
@@ -7,7 +7,6 @@ import {
GlTokenSelector,
GlFormCombobox,
} from '@gitlab/ui';
-import { mapState } from 'vuex';
import { i18n } from '../constants';
export default {
@@ -37,9 +36,10 @@ export default {
type: Boolean,
required: true,
},
- },
- computed: {
- ...mapState(['availableStages']),
+ availableStages: {
+ type: Array,
+ required: true,
+ },
},
};
</script>
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
new file mode 100644
index 00000000000..d068b370852
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants';
+
+export default {
+ i18n,
+ whenOptions: Object.values(JOB_RULES_WHEN),
+ unitOptions: Object.values(JOB_RULES_START_IN),
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ isStartValid: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ startInNumber: 1,
+ startInUnit: JOB_RULES_START_IN.second.value,
+ };
+ },
+ computed: {
+ isDelayed() {
+ return this.job.rules[0].when === JOB_RULES_WHEN.delayed.value;
+ },
+ },
+ methods: {
+ updateStartIn() {
+ const plural = this.startInNumber > 1 ? 's' : '';
+ this.$emit(
+ 'update-job',
+ 'rules[0].start_in',
+ `${this.startInNumber} ${this.startInUnit}${plural}`,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.RULES">
+ <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
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ :options="$options.whenOptions"
+ data-testid="rules-when-select"
+ :value="job.rules[0].when"
+ @input="$emit('update-job', 'rules[0].when', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ :invalid-feedback="$options.i18n.INVALID_START_IN"
+ :state="isStartValid"
+ >
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-input
+ v-model="startInNumber"
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ data-testid="rules-start-in-number-input"
+ type="number"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ number
+ @input="updateStartIn"
+ />
+ <gl-form-select
+ v-model="startInUnit"
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ data-testid="rules-start-in-unit-select"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ :options="$options.unitOptions"
+ @input="updateStartIn"
+ />
+ </div>
+ </gl-form-group>
+ </div>
+ <gl-form-group>
+ <gl-form-checkbox
+ :checked="job.rules[0].allow_failure"
+ data-testid="rules-allow-failure-checkbox"
+ @input="$emit('update-job', 'rules[0].allow_failure', $event)"
+ >
+ {{ $options.i18n.ALLOW_FAILURE }}
+ </gl-form-checkbox>
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
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 994a6e719fe..df3a2c64e25 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
@@ -2,6 +2,59 @@ import { __, s__ } from '~/locale';
export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+export const JOB_RULES_WHEN = {
+ onSuccess: {
+ value: 'on_success',
+ text: s__('JobAssistant|on_success'),
+ },
+ onFailure: {
+ value: 'on_failure',
+ text: s__('JobAssistant|on_failure'),
+ },
+ manual: {
+ value: 'manual',
+ text: s__('JobAssistant|manual'),
+ },
+ always: {
+ value: 'always',
+ text: s__('JobAssistant|always'),
+ },
+ delayed: {
+ value: 'delayed',
+ text: s__('JobAssistant|delayed'),
+ },
+ never: {
+ value: 'never',
+ text: s__('JobAssistant|never'),
+ },
+};
+
+export const JOB_RULES_START_IN = {
+ second: {
+ value: 'second',
+ text: s__('JobAssistant|second(s)'),
+ },
+ minute: {
+ value: 'minute',
+ text: s__('JobAssistant|minute(s)'),
+ },
+ day: {
+ value: 'day',
+ text: s__('JobAssistant|day(s)'),
+ },
+ week: {
+ value: 'week',
+ text: s__('JobAssistant|week(s)'),
+ },
+};
+
+export const SECONDS_MULTIPLE_MAP = {
+ second: 1,
+ minute: 60,
+ day: 3600 * 24,
+ week: 3600 * 24 * 7,
+};
+
export const JOB_TEMPLATE = {
name: '',
stage: '',
@@ -25,6 +78,13 @@ export const JOB_TEMPLATE = {
paths: [''],
key: '',
},
+ rules: [
+ {
+ allow_failure: false,
+ when: 'on_success',
+ start_in: '',
+ },
+ ],
};
export const i18n = {
@@ -38,4 +98,14 @@ export const i18n = {
IMAGE_NAME: s__('JobAssistant|Image name (optional)'),
IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'),
THIS_FIELD_IS_REQUIRED: __('This field is required'),
+ CACHE_PATHS: s__('JobAssistant|Cache paths (optional)'),
+ CACHE_KEY: s__('JobAssistant|Cache key (optional)'),
+ ARTIFACTS_EXCLUDE_PATHS: s__('JobAssistant|Artifacts exclude paths (optional)'),
+ ARTIFACTS_PATHS: s__('JobAssistant|Artifacts paths (optional)'),
+ ARTIFACTS_AND_CACHE: s__('JobAssistant|Artifacts and cache'),
+ ADD_PATH: s__('JobAssistant|Add path'),
+ RULES: s__('JobAssistant|Rules'),
+ WHEN: s__('JobAssistant|When'),
+ ALLOW_FAILURE: s__('JobAssistant|Allow failure'),
+ INVALID_START_IN: s__('JobAssistant|Error - Valid value is between 1 second and 1 week'),
};
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 9f68b97b329..8cde20bc22e 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
@@ -1,16 +1,16 @@
<script>
import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
-import { stringify } from 'yaml';
-import { mapMutations, mapState } from 'vuex';
-import { set, omit, trim } from 'lodash';
+import { stringify, parse } from 'yaml';
+import { get, omit, toPath } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
-import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types';
-import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
-import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
-import { removeEmptyObj, trimFields } from './utils';
+import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
+import { DRAWER_CONTAINER_CLASS, 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';
+import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue';
+import RulesItem from './accordion_items/rules_item.vue';
export default {
i18n,
@@ -20,6 +20,8 @@ export default {
GlButton,
JobSetupItem,
ImageItem,
+ ArtifactsAndCacheItem,
+ RulesItem,
},
props: {
isVisible: {
@@ -32,24 +34,38 @@ export default {
required: false,
default: 200,
},
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
isNameValid: true,
isScriptValid: true,
+ isStartValid: true,
job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
};
},
apollo: {
runners: {
- query: getAllRunners,
+ query: getRunnerTags,
update(data) {
return data?.runners?.nodes || [];
},
},
},
computed: {
- ...mapState(['currentCiFileContent']),
+ availableStages() {
+ if (this.ciConfigData?.mergedYaml) {
+ return parse(this.ciConfigData.mergedYaml).stages;
+ }
+ return [];
+ },
tagOptions() {
const options = [];
this.runners?.forEach((runner) => options.push(...runner.tagList));
@@ -63,25 +79,36 @@ export default {
drawerHeightOffset() {
return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
},
+ isJobValid() {
+ return this.isNameValid && this.isScriptValid && this.isStartValid;
+ },
+ },
+
+ watch: {
+ 'job.name': function jobNameWatch(name) {
+ this.isNameValid = validateEmptyValue(name);
+ },
+ 'job.script': function jobScriptWatch(script) {
+ this.isScriptValid = validateEmptyValue(script);
+ },
+ 'job.rules.0.start_in': function JobRulesStartInWatch(startIn) {
+ this.isStartValid = validateStartIn(this.job.rules[0].when, startIn);
+ },
},
methods: {
- ...mapMutations({
- updateCiConfig: UPDATE_CI_CONFIG,
- }),
closeDrawer() {
this.clearJob();
this.$emit('close-job-assistant-drawer');
},
addCiConfig() {
- this.isNameValid = this.validate(this.job.name);
- this.isScriptValid = this.validate(this.job.script);
+ this.validateJob();
- if (!this.isNameValid || !this.isScriptValid) {
+ if (!this.isJobValid) {
return;
}
const newJobString = this.generateYmlString();
- this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`);
+ this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`);
eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
this.closeDrawer();
@@ -89,27 +116,53 @@ export default {
generateYmlString() {
let job = JSON.parse(JSON.stringify(this.job));
const jobName = job.name;
- job = omit(job, ['name']);
+ job = this.removeUnnecessaryKeys(job);
job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules
const cleanedJob = trimFields(removeEmptyObj(job));
return stringify({ [jobName]: cleanedJob });
},
+ removeUnnecessaryKeys(job) {
+ const keys = ['name'];
+
+ // rules[0].allow_failure value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].allow_failure === false) {
+ keys.push('rules[0].allow_failure');
+ }
+ // rules[0].when value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].when === JOB_RULES_WHEN.onSuccess.value) {
+ keys.push('rules[0].when');
+ }
+ // rules[0].start_in value should not be passed down
+ // if rules[0].start_in doesn't equal 'delayed'
+ if (this.job.rules[0].when !== JOB_RULES_WHEN.delayed.value) {
+ keys.push('rules[0].start_in');
+ }
+ return omit(job, keys);
+ },
clearJob() {
this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE));
- this.isNameValid = true;
- this.isScriptValid = true;
+ this.$nextTick(() => {
+ this.isNameValid = true;
+ this.isScriptValid = true;
+ this.isStartValid = true;
+ });
},
updateJob(key, value) {
- set(this.job, key, value);
- if (key === 'name') {
- this.isNameValid = this.validate(this.job.name);
- }
- if (key === 'script') {
- this.isScriptValid = this.validate(this.job.script);
+ const path = toPath(key);
+ const targetObj = path.length === 1 ? this.job : get(this.job, path.slice(0, -1));
+ const lastKey = path[path.length - 1];
+ if (value !== undefined) {
+ this.$set(targetObj, lastKey, value);
+ } else {
+ this.$delete(targetObj, lastKey);
}
},
- validate(value) {
- return trim(value) !== '';
+ validateJob() {
+ this.isNameValid = validateEmptyValue(this.job.name);
+ this.isScriptValid = validateEmptyValue(this.job.script);
+ this.isStartValid = validateStartIn(this.job.rules[0].when, this.job.rules[0].start_in);
},
},
};
@@ -131,9 +184,12 @@ export default {
:job="job"
:is-name-valid="isNameValid"
:is-script-valid="isScriptValid"
+ :available-stages="availableStages"
@update-job="updateJob"
/>
<image-item :job="job" @update-job="updateJob" />
+ <artifacts-and-cache-item :job="job" @update-job="updateJob" />
+ <rules-item :job="job" :is-start-valid="isStartValid" @update-job="updateJob" />
</gl-accordion>
<template #footer>
<div class="gl-display-flex gl-justify-content-end">
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
index 83e7574c4de..a604d79259d 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
@@ -1,4 +1,8 @@
import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash';
+import {
+ JOB_RULES_WHEN,
+ SECONDS_MULTIPLE_MAP,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val);
const trimText = (val) => (isString(val) ? trim(val) : val);
@@ -20,3 +24,30 @@ export const trimFields = (data) => {
}
return trimText(data);
};
+
+export const validateEmptyValue = (value) => {
+ return trim(value) !== '';
+};
+
+export const validateStartIn = (when, startIn) => {
+ const hasNoValue = when !== JOB_RULES_WHEN.delayed.value;
+ if (hasNoValue) {
+ return true;
+ }
+
+ let [startInNumber, startInUnit] = startIn.split(' ');
+
+ startInNumber = Number(startInNumber);
+ if (!Number.isInteger(startInNumber)) {
+ return false;
+ }
+
+ const isPlural = startInUnit.slice(-1) === 's';
+ if (isPlural) {
+ startInUnit = startInUnit.slice(0, -1);
+ }
+
+ const multiple = SECONDS_MULTIPLE_MAP[startInUnit];
+
+ return startInNumber * multiple >= 1 && startInNumber * multiple <= SECONDS_MULTIPLE_MAP.week;
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
index dd25c4d433b..e775dc5147a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -86,25 +86,8 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-
export const COMMIT_SHA_POLL_INTERVAL = 1000;
-export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
-export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
-export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
-export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
export const I18N = {
title: s__('Pipelines|Get started with GitLab CI/CD'),
- runners: {
- title: s__('Pipelines|Runners are available to run your jobs now'),
- subtitle: s__(
- 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
- ),
- },
- noRunners: {
- title: s__('Pipelines|No runners detected'),
- subtitle: s__(
- 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
- ),
- cta: s__('Pipelines|Install GitLab Runner'),
- },
learnBasics: {
title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
subtitle: s__(
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
new file mode 100644
index 00000000000..aab30257d13
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerTags {
+ runners {
+ nodes {
+ id
+ tagList
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
index d65a7c321ce..09acd805410 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -12,7 +12,6 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
-import createStore from './store';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
@@ -112,11 +111,8 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
},
});
- const store = createStore();
-
return new Vue({
el,
- store,
apolloProvider,
provide: {
ciConfigPath,
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
index 7b3c4d6f74f..ff848a973e3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -1,12 +1,10 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { mapState, mapMutations } from 'vuex';
-import { parse } from 'yaml';
import { fetchPolicies } from '~/lib/graphql';
import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
-import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
@@ -46,6 +44,7 @@ export default {
data() {
return {
ciConfigData: {},
+ currentCiFileContent: '',
failureType: null,
failureReasons: [],
hasBranchLoaded: false,
@@ -95,7 +94,7 @@ export default {
const fileContent = rawBlob ?? '';
this.lastCommittedContent = fileContent;
- this.updateCiConfig(fileContent);
+ this.currentCiFileContent = fileContent;
// If rawBlob is defined and returns a string, it means that there is
// a CI config file with empty content. If `rawBlob` is not defined
@@ -156,10 +155,6 @@ export default {
this.isLintUnavailable = false;
}
}
-
- if (data?.ciConfig?.mergedYaml) {
- this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages);
- }
},
error() {
// We are not using `reportFailure` here because we don't
@@ -236,7 +231,6 @@ export default {
},
},
computed: {
- ...mapState(['currentCiFileContent']),
hasUnsavedChanges() {
return this.lastCommittedContent !== this.currentCiFileContent;
},
@@ -300,10 +294,6 @@ export default {
this.checkShouldSkipStartScreen();
},
methods: {
- ...mapMutations({
- updateCiConfig: UPDATE_CI_CONFIG,
- updateAvailableStages: UPDATE_AVAILABLE_STAGES,
- }),
checkShouldSkipStartScreen() {
const params = queryToObject(window.location.search);
this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
@@ -354,7 +344,7 @@ export default {
},
resetContent() {
this.showResetConfirmationModal = false;
- this.updateCiConfig(this.lastCommittedContent);
+ this.currentCiFileContent = this.lastCommittedContent;
},
setAppStatus(appStatus) {
if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) {
@@ -371,6 +361,9 @@ export default {
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
+ updateCiConfig(ciFileContent) {
+ this.currentCiFileContent = ciFileContent;
+ },
updateCommitSha() {
this.isFetchingCommitSha = true;
this.$apollo.queries.commitSha.refetch();
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 59863edbe0b..1329042ee4c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -195,6 +195,8 @@ export default {
@close-drawer="closeDrawer"
/>
<job-assistant-drawer
+ :ci-config-data="ciConfigData"
+ :ci-file-content="ciFileContent"
:is-visible="showJobAssistantDrawer"
:z-index="jobAssistantIndex"
v-on="$listeners"
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js
deleted file mode 100644
index d7d5aed79e2..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export default () =>
- new Vuex.Store({
- mutations,
- state: state(),
- });
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
deleted file mode 100644
index 035d3c90c14..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG';
-export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES';
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
deleted file mode 100644
index 552c1df9a2c..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.UPDATE_CI_CONFIG](state, content) {
- state.currentCiFileContent = content;
- },
- [types.UPDATE_AVAILABLE_STAGES](state, stages) {
- state.availableStages = stages || [];
- },
-};
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js
deleted file mode 100644
index 34146cd54c4..00000000000
--- a/app/assets/javascripts/ci/pipeline_editor/store/state.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export default () => ({
- currentCiFileContent: '',
- availableStages: [],
-});
diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
index 648cd8b66b5..f93f5ad4f11 100644
--- a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
@@ -1,7 +1,7 @@
query ciConfigVariables($fullPath: ID!, $ref: String!) {
project(fullPath: $fullPath) {
id
- ciConfigVariables(sha: $ref) {
+ ciConfigVariables(ref: $ref) {
description
key
value
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
index 56461165588..92f461c72d7 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="last-pipeline-status">
<ci-badge-link
v-if="hasPipeline"
:status="lastPipelineStatus"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
index 48d59bf6e7c..9c0fc148dac 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="next-run-cell">
<time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" />
<span v-else data-testid="pipeline-schedule-inactive">
{{ s__('PipelineSchedules|Inactive') }}
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index 0b95e2037e8..b97914f8c26 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -68,7 +68,12 @@ export default {
</script>
<template>
- <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md">
+ <gl-table-lite
+ :fields="$options.fields"
+ :items="schedules"
+ :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }"
+ stacked="md"
+ >
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
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 79600012838..43d0dae6e78 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
@@ -6,7 +6,7 @@ import { s__ } from '~/locale';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
-import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM } from '../constants';
+import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
@@ -34,21 +34,21 @@ export default {
},
methods: {
onSaved(runner) {
- const registerUrl = setUrlParams(
- { [PARAM_KEY_PLATFORM]: this.platform },
- runner.registerAdminUrl,
- );
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
saveAlertToLocalStorage({
message: s__('Runners|Runner created.'),
variant: VARIANT_SUCCESS,
});
- redirectTo(registerUrl);
+ redirectTo(ephemeralRegisterUrl);
},
onError(error) {
createAlert({ message: error.message });
},
},
modalId: 'runners-legacy-registration-instructions-modal',
+ INSTANCE_TYPE,
};
</script>
@@ -84,6 +84,6 @@ export default {
<hr aria-hidden="true" />
- <runner-create-form @saved="onSaved" @error="onError" />
+ <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" />
</div>
</template>
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 97dfbe1a051..24c1b4f5c3b 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
@@ -1,6 +1,8 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
@@ -14,6 +16,7 @@ import {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
} from '../../constants';
import RunnerSummaryField from './runner_summary_field.vue';
@@ -28,6 +31,7 @@ export default {
RunnerTypeBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
+ UserAvatarLink,
TooltipOnTruncate,
},
directives: {
@@ -43,6 +47,16 @@ export default {
jobCount() {
return formatJobCount(this.runner.jobCount);
},
+ createdBy() {
+ return this.runner?.createdBy;
+ },
+ createdByImgAlt() {
+ const name = this.createdBy?.name;
+ if (name) {
+ return sprintf(__("%{name}'s avatar"), { name });
+ }
+ return null;
+ },
},
i18n: {
I18N_NO_DESCRIPTION,
@@ -50,6 +64,7 @@ export default {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
},
};
</script>
@@ -106,11 +121,30 @@ export default {
</runner-summary-field>
<runner-summary-field icon="calendar">
- <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
- <template #timeAgo>
- <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
- </template>
- </gl-sprintf>
+ <template v-if="createdBy">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ <template #avatar>
+ <user-avatar-link
+ :link-href="createdBy.webUrl"
+ :img-src="createdBy.avatarUrl"
+ img-css-classes="gl-vertical-align-top"
+ :img-size="16"
+ :img-alt="createdByImgAlt"
+ :tooltip-text="createdBy.username"
+ />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
</runner-summary-field>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
index 1bbbd55089a..20681873436 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-4">
<gl-icon v-if="icon" :name="icon" />
<!-- display tooltip as a label for screen readers -->
<span class="gl-sr-only">{{ tooltip }}</span>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
index 2f3c172666d..69021dde0e9 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -70,7 +70,7 @@ export default {
captureException({ error, component: this.$options.name });
},
pollInterval() {
- if (this.runner?.status === STATUS_ONLINE) {
+ if (this.isRunnerOnline) {
// stop polling
return 0;
}
@@ -97,9 +97,6 @@ export default {
}
return s__('Runners|Register runner');
},
- status() {
- return this.runner?.status;
- },
tokenMessage() {
if (this.token) {
return s__(
@@ -116,22 +113,40 @@ export default {
registerCommand() {
return registerCommand({
platform: this.platform,
- registrationToken: this.token,
- description: this.description,
+ token: this.token,
});
},
runCommand() {
return runCommand({ platform: this.platform });
},
+ isRunnerOnline() {
+ return this.runner?.status === STATUS_ONLINE;
+ },
+ },
+ created() {
+ window.addEventListener('beforeunload', this.onBeforeunload);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.onBeforeunload);
},
methods: {
toggleDrawer() {
this.$emit('toggleDrawer');
},
+ onBeforeunload(event) {
+ if (this.isRunnerOnline) {
+ return undefined;
+ }
+
+ const str = s__('Runners|You may lose access to the runner token if you leave this page.');
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = str; // Chrome requires returnValue to be set
+ return str;
+ },
},
EXECUTORS_HELP_URL,
SERVICE_COMMANDS_HELP_URL,
- STATUS_ONLINE,
I18N_REGISTRATION_SUCCESS,
};
</script>
@@ -226,7 +241,7 @@ export default {
</gl-sprintf>
</p>
</section>
- <section v-if="status == $options.STATUS_ONLINE">
+ <section v-if="isRunnerOnline">
<h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2>
<p class="gl-pl-6">
diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js
index 94d75bc4562..c8a75506c9c 100644
--- a/app/assets/javascripts/ci/runner/components/registration/utils.js
+++ b/app/assets/javascripts/ci/runner/components/registration/utils.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import {
DEFAULT_PLATFORM,
LINUX_PLATFORM,
@@ -28,20 +27,6 @@ const OS = {
},
};
-const escapedParam = (param, shell = 'bash') => {
- let escaped;
- if (shell === 'bash') {
- // replace single-quotes by the sequence '\''
- escaped = param.replaceAll("'", "'\\''");
- } else if (shell === 'powershell') {
- // replace single-quotes by the sequence ''
- // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.3
- escaped = param.replaceAll("'", "''");
- }
- // surround with single quotes.
- return `'${escaped}'`;
-};
-
export const commandPrompt = ({ platform }) => {
return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt;
};
@@ -50,32 +35,19 @@ export const executable = ({ platform }) => {
return (OS[platform] || OS[DEFAULT_PLATFORM]).executable;
};
-const shell = ({ platform }) => {
- return (OS[platform] || OS[DEFAULT_PLATFORM]).shell;
-};
-
-export const registerCommand = ({
- platform,
- url = gon.gitlab_url,
- registrationToken,
- description,
-}) => {
- const lines = [`${executable({ platform })} register`];
+export const registerCommand = ({ platform, url = gon.gitlab_url, token }) => {
+ const lines = [`${executable({ platform })} register`]; // eslint-disable-line @gitlab/require-i18n-strings
if (url) {
lines.push(` --url ${url}`);
}
- if (registrationToken) {
- lines.push(` --registration-token ${registrationToken}`);
- }
- if (description) {
- const escapedDescription = escapedParam(description, shell({ platform }));
- lines.push(` --description ${escapedDescription}`);
+ if (token) {
+ lines.push(` --token ${token}`);
}
return lines;
};
export const runCommand = ({ platform }) => {
- return `${executable({ platform })} run`;
+ return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings
};
const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => {
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 5d2a3c53842..d3e02f5cd6e 100644
--- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -4,7 +4,7 @@ import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql';
import { modelToUpdateMutationVariables } from 'ee_else_ce/ci/runner/runner_update_form_utils';
import { captureException } from '../sentry_utils';
-import { DEFAULT_ACCESS_LEVEL } from '../constants';
+import { RUNNER_TYPES, DEFAULT_ACCESS_LEVEL, GROUP_TYPE, INSTANCE_TYPE } from '../constants';
export default {
name: 'RunnerCreateForm',
@@ -13,6 +13,18 @@ export default {
GlButton,
RunnerFormFields,
},
+ props: {
+ runnerType: {
+ type: String,
+ required: true,
+ validator: (t) => RUNNER_TYPES.includes(t),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
data() {
return {
saving: false,
@@ -27,6 +39,23 @@ export default {
},
};
},
+ computed: {
+ mutationInput() {
+ const { input } = modelToUpdateMutationVariables(this.runner);
+
+ if (this.runnerType === GROUP_TYPE) {
+ return {
+ ...input,
+ runnerType: GROUP_TYPE,
+ groupId: this.groupId,
+ };
+ }
+ return {
+ ...input,
+ runnerType: INSTANCE_TYPE,
+ };
+ },
+ },
methods: {
async onSubmit() {
this.saving = true;
@@ -37,7 +66,9 @@ export default {
},
} = await this.$apollo.mutate({
mutation: runnerCreateMutation,
- variables: modelToUpdateMutationVariables(this.runner),
+ variables: {
+ input: this.mutationInput,
+ },
});
if (errors?.length) {
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 6237dcd0c03..1cae9df713b 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -93,6 +93,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
+export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}');
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
export const I18N_ADMIN = s__('Runners|Administrator');
@@ -141,6 +142,7 @@ export const PARAM_KEY_PLATFORM = 'platform';
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
+export const RUNNER_TYPES = [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE];
// CiRunnerStatus
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 6f72509f599..0a449ef0435 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
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
fragment ListItemShared on CiRunner {
id
description
@@ -10,6 +12,9 @@ fragment ListItemShared on CiRunner {
jobCount
tagList
createdAt
+ createdBy {
+ ...User
+ }
contactedAt
status(legacyMode: null)
jobExecutionStatus
diff --git a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
index d14a594e378..07236808dca 100644
--- a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
@@ -2,7 +2,7 @@ mutation runnerCreate($input: RunnerCreateInput!) {
runnerCreate(input: $input) {
runner {
id
- registerAdminUrl
+ ephemeralRegisterUrl
}
errors
}
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
new file mode 100644
index 00000000000..35c75a917c7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'GroupNewRunnerApp',
+ components: {
+ GlLink,
+ GlSprintf,
+ RunnerInstructionsModal,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ legacyRegistrationToken: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ };
+ },
+ methods: {
+ onSaved(runner) {
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(ephemeralRegisterUrl);
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ modalId: 'runners-legacy-registration-instructions-modal',
+ GROUP_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Create a group runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{
+ content
+ }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="legacyRegistrationToken"
+ />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
+
+ <hr aria-hidden="true" />
+
+ <runner-create-form
+ :runner-type="$options.GROUP_TYPE"
+ :group-id="groupId"
+ @saved="onSaved"
+ @error="onError"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/index.js b/app/assets/javascripts/ci/runner/group_new_runner/index.js
new file mode 100644
index 00000000000..b314c3aa1e7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import GroupNewRunnerApp from './group_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupNewRunner = (selector = '#js-group-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { legacyRegistrationToken, groupId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupNewRunnerApp, {
+ props: {
+ groupId,
+ legacyRegistrationToken,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue
new file mode 100644
index 00000000000..533d31b70a3
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { getParameterByName, updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM } from '../constants';
+import RegistrationInstructions from '../components/registration/registration_instructions.vue';
+import PlatformsDrawer from '../components/registration/platforms_drawer.vue';
+
+export default {
+ name: 'GroupRegisterRunnerApp',
+ components: {
+ GlButton,
+ RegistrationInstructions,
+ PlatformsDrawer,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ runnersPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: getParameterByName(PARAM_KEY_PLATFORM) || DEFAULT_PLATFORM,
+ isDrawerOpen: false,
+ };
+ },
+ watch: {
+ platform(platform) {
+ updateHistory({
+ url: mergeUrlParams({ [PARAM_KEY_PLATFORM]: platform }, window.location.href),
+ });
+ },
+ },
+ methods: {
+ onSelectPlatform(platform) {
+ this.platform = platform;
+ },
+ onToggleDrawer(val = !this.isDrawerOpen) {
+ this.isDrawerOpen = val;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <registration-instructions
+ :runner-id="runnerId"
+ :platform="platform"
+ @toggleDrawer="onToggleDrawer"
+ >
+ <template #runner-list-name>{{ s__('Runners|Group area › Runners') }}</template>
+ </registration-instructions>
+
+ <platforms-drawer
+ :platform="platform"
+ :open="isDrawerOpen"
+ @selectPlatform="onSelectPlatform"
+ @close="onToggleDrawer(false)"
+ />
+
+ <gl-button :href="runnersPath" variant="confirm">{{
+ s__('Runners|Go to runners page')
+ }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/group_register_runner/index.js b/app/assets/javascripts/ci/runner/group_register_runner/index.js
new file mode 100644
index 00000000000..a00db8853a2
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_register_runner/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
+import GroupRegisterRunnerApp from './group_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupRegisterRunner = (selector = '#js-group-register-runner') => {
+ showAlertFromLocalStorage();
+
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId, runnersPath } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 294d06a66e7..f8386214698 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
@@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlButton,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -58,6 +59,11 @@ export default {
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
+ newRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
registrationToken: {
type: String,
required: false,
@@ -150,6 +156,10 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow_for_namespace feature flag
+ return this.glFeatures.createRunnerWorkflowForNamespace;
+ },
},
watch: {
search: {
@@ -219,8 +229,13 @@ export default {
nav-class="gl-border-none!"
/>
+ <template v-if="shouldShowCreateRunnerWorkflow">
+ <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm">
+ {{ s__('Runners|New group runner') }}
+ </gl-button>
+ </template>
<registration-dropdown
- v-if="registrationToken"
+ v-else-if="registrationToken"
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 46514d5afe8..4fcf484317d 100644
--- a/app/assets/javascripts/ci/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
@@ -18,6 +18,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
const {
registrationToken,
runnerInstallHelpPage,
+ newRunnerPath,
groupId,
groupFullPath,
onlineContactTimeoutSecs,
@@ -49,6 +50,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: {
registrationToken,
groupFullPath,
+ newRunnerPath,
},
});
},
diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
index ca65665b9ed..24a776e1a29 100644
--- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
+++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
@@ -164,7 +164,7 @@ export default {
:href="$options.emptyHelpLink"
:title="$options.i18n.emptyTooltip"
:aria-label="$options.i18n.emptyTooltip"
- ><gl-icon name="question" :size="14"
+ ><gl-icon name="question-o" :size="14"
/></gl-link>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index e0e3b961c51..d7e98638a11 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -8,9 +8,13 @@ import {
GlTooltipDirective,
GlPopover,
} from '@gitlab/ui';
+import semverLt from 'semver/functions/lt';
+import semverInc from 'semver/functions/inc';
+import semverPrerelease from 'semver/functions/prerelease';
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 { getAgentConfigPath } from '../clusters_util';
import DeleteAgentButton from './delete_agent_button.vue';
@@ -81,6 +85,11 @@ export default {
tdClass,
},
{
+ key: 'agentID',
+ label: this.$options.i18n.agentIdLabel,
+ tdClass,
+ },
+ {
key: 'configuration',
label: this.$options.i18n.configurationLabel,
tdClass,
@@ -116,6 +125,9 @@ export default {
getPopoverTestId(item) {
return `popover-${item.name}`;
},
+ getAgentId(item) {
+ return getIdFromGraphQLId(item.id);
+ },
getAgentConfigPath,
getAgentVersions(agent) {
const agentConnections = agent.connections?.nodes || [];
@@ -134,18 +146,26 @@ export default {
isVersionMismatch(agent) {
return agent.versions.length > 1;
},
+ // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version
+ // using the following heuristics:
+ // - KAS Version is used as *server* version if available, otherwise the GitLab version is used.
+ // - returns `outdated` if the agent has a different major version than the server
+ // - returns `outdated` if the agents minor version is at least two proper versions older than the server
+ // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version
+ //
+ // Note that it does NOT support if the agent is newer than the server version.
isVersionOutdated(agent) {
if (!agent.versions.length) return false;
- const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
- const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.');
-
- const majorVersionMismatch = agentMajorVersion !== serverMajorVersion;
+ const agentVersion = this.getAgentVersionString(agent);
+ let allowableAgentVersion = semverInc(agentVersion, 'minor');
- // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
- const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1;
+ const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion));
+ if (isServerPrerelease) {
+ allowableAgentVersion = semverInc(allowableAgentVersion, 'minor');
+ }
- return majorVersionMismatch || minorVersionMismatch;
+ return semverLt(allowableAgentVersion, this.serverVersion);
},
getVersionPopoverTitle(agent) {
@@ -265,6 +285,12 @@ export default {
</gl-popover>
</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">
@@ -279,7 +305,7 @@ export default {
:title="$options.i18n.defaultConfigTooltip"
:aria-label="$options.i18n.defaultConfigTooltip"
class="gl-vertical-align-middle"
- ><gl-icon name="question" :size="14" /></gl-link
+ ><gl-icon name="question-o" :size="14" /></gl-link
></span>
</span>
</template>
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue
index e4b481f0908..9e0d2cc73ec 100644
--- a/app/assets/javascripts/saved_replies/components/app.vue
+++ b/app/assets/javascripts/comment_templates/components/app.vue
@@ -6,12 +6,12 @@ export default {};
<div class="row gl-mt-5">
<div class="col-lg-4">
<h4 class="gl-mt-0">
- {{ __('Saved Replies') }}
+ {{ __('Comment templates') }}
</h4>
<p>
{{
__(
- 'Saved replies can be used when creating comments inside issues, merge requests, and epics.',
+ 'Comment templates can be used when creating comments inside issues, merge requests, and epics.',
)
}}
</p>
diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
index efec9b96764..47efccc3d0c 100644
--- a/app/assets/javascripts/saved_replies/components/form.vue
+++ b/app/assets/javascripts/comment_templates/components/form.vue
@@ -38,7 +38,7 @@ export default {
errors: [],
saving: false,
showValidation: false,
- updateSavedReply: {
+ updateCommentTemplate: {
name: this.name,
content: this.content,
},
@@ -46,12 +46,12 @@ export default {
},
computed: {
isNameValid() {
- if (this.showValidation) return Boolean(this.updateSavedReply.name);
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.name);
return true;
},
isContentValid() {
- if (this.showValidation) return Boolean(this.updateSavedReply.content);
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.content);
return true;
},
@@ -73,15 +73,15 @@ export default {
mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation,
variables: {
id: this.id,
- name: this.updateSavedReply.name,
- content: this.updateSavedReply.content,
+ name: this.updateCommentTemplate.name,
+ content: this.updateCommentTemplate.content,
},
update: (store, { data: { savedReplyMutation } }) => {
if (savedReplyMutation.errors.length) {
this.errors = savedReplyMutation.errors.map((e) => e);
} else {
this.$emit('saved');
- this.updateSavedReply = { name: '', content: '' };
+ this.updateCommentTemplate = { name: '', content: '' };
this.showValidation = false;
}
},
@@ -112,8 +112,8 @@ export default {
<template>
<gl-form
- class="new-note common-note-form"
- data-testid="saved-reply-form"
+ class="new-note common-note-form gl-mb-6"
+ data-testid="comment-template-form"
@submit.prevent="onSubmit"
>
<gl-alert
@@ -128,26 +128,26 @@ export default {
<gl-form-group
:label="__('Name')"
:state="isNameValid"
- :invalid-feedback="__('Please enter a name for the saved reply.')"
- data-testid="saved-reply-name-form-group"
+ :invalid-feedback="__('Please enter a name for the comment template.')"
+ data-testid="comment-template-name-form-group"
>
<gl-form-input
- v-model="updateSavedReply.name"
- :placeholder="__('Enter a name for your saved reply')"
- data-testid="saved-reply-name-input"
+ v-model="updateCommentTemplate.name"
+ :placeholder="__('Enter a name for your comment template')"
+ data-testid="comment-template-name-input"
/>
</gl-form-group>
<gl-form-group
:label="__('Content')"
:state="isContentValid"
- :invalid-feedback="__('Please enter the saved reply content.')"
- data-testid="saved-reply-content-form-group"
+ :invalid-feedback="__('Please enter the comment template content.')"
+ data-testid="comment-template-content-form-group"
>
<markdown-field
:enable-preview="false"
:is-submitting="saving"
:add-spacing-classes="false"
- :textarea-value="updateSavedReply.content"
+ :textarea-value="updateCommentTemplate.content"
:markdown-docs-path="$options.markdownDocsPath"
:restricted-tool-bar-items="$options.restrictedToolbarItems"
:force-autosize="false"
@@ -155,13 +155,13 @@ export default {
>
<template #textarea>
<textarea
- v-model="updateSavedReply.content"
+ v-model="updateCommentTemplate.content"
dir="auto"
class="note-textarea js-gfm-input js-autosize markdown-area"
data-supports-quick-actions="false"
:aria-label="__('Content')"
- :placeholder="__('Write saved reply content here…')"
- data-testid="saved-reply-content-input"
+ :placeholder="__('Write comment template content here…')"
+ data-testid="comment-template-content-input"
@keydown.meta.enter="onSubmit"
@keydown.ctrl.enter="onSubmit"
></textarea>
@@ -173,7 +173,7 @@ export default {
class="gl-mr-3 js-no-auto-disable"
type="submit"
:loading="saving"
- data-testid="saved-reply-form-submit-btn"
+ data-testid="comment-template-form-submit-btn"
>
{{ __('Save') }}
</gl-button>
diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
index dbe326d429a..52bebfd050c 100644
--- a/app/assets/javascripts/saved_replies/components/list.vue
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -44,16 +44,16 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-border-t gl-pt-4">
<gl-loading-icon v-if="loading" size="lg" />
<template v-else>
<h5 class="gl-font-lg" data-testid="title">
- <gl-sprintf :message="__('My saved replies (%{count})')">
+ <gl-sprintf :message="__('My comment templates (%{count})')">
<template #count>{{ count }}</template>
</gl-sprintf>
</h5>
<ul class="gl-list-style-none gl-p-0 gl-m-0">
- <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" />
+ <list-item v-for="template in savedReplies" :key="template.id" :template="template" />
</ul>
<gl-keyset-pagination
v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue
new file mode 100644
index 00000000000..d763700db42
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/list_item.vue
@@ -0,0 +1,116 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ template: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleting: false,
+ modalId: uniqueId('delete-comment-template-'),
+ toggleId: uniqueId('actions-toggle-'),
+ };
+ },
+ computed: {
+ id() {
+ return getIdFromGraphQLId(this.template.id);
+ },
+ dropdownItems() {
+ return [
+ {
+ text: __('Edit'),
+ action: () => this.$router.push({ name: 'edit', params: { id: this.id } }),
+ extraAttrs: {
+ 'data-testid': 'comment-template-edit-btn',
+ },
+ },
+ {
+ text: __('Delete'),
+ action: () => this.$refs['delete-modal'].show(),
+ extraAttrs: {
+ 'data-testid': 'comment-template-delete-btn',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
+ },
+ methods: {
+ onDelete() {
+ this.isDeleting = true;
+
+ this.$apollo.mutate({
+ mutation: deleteSavedReplyMutation,
+ variables: {
+ id: this.template.id,
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(this.template);
+ cache.evict({ id: cacheId });
+ },
+ });
+ },
+ },
+ actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
+ actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
+};
+</script>
+
+<template>
+ <li class="gl-pt-4 gl-pb-5 gl-border-b">
+ <div class="gl-display-flex gl-align-items-center">
+ <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6>
+ <div class="gl-ml-auto">
+ <gl-disclosure-dropdown
+ :items="dropdownItems"
+ :toggle-id="toggleId"
+ icon="ellipsis_v"
+ no-caret
+ text-sr-only
+ placement="right"
+ :toggle-text="__('Comment template actions')"
+ :loading="isDeleting"
+ category="tertiary"
+ />
+ <gl-tooltip :target="toggleId">
+ {{ __('Comment template actions') }}
+ </gl-tooltip>
+ </div>
+ </div>
+ <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div>
+ <gl-modal
+ ref="delete-modal"
+ :title="__('Delete comment template')"
+ :action-primary="$options.actionPrimary"
+ :action-secondary="$options.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="onDelete"
+ >
+ <gl-sprintf
+ :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
+ >
+ <template #name
+ ><strong>{{ template.name }}</strong></template
+ >
+ </gl-sprintf>
+ </gl-modal>
+ </li>
+</template>
diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/comment_templates/index.js
index 5022ff62b10..8cd763e7a9e 100644
--- a/app/assets/javascripts/saved_replies/index.js
+++ b/app/assets/javascripts/comment_templates/index.js
@@ -5,11 +5,11 @@ import createDefaultClient from '~/lib/graphql';
import routes from './routes';
import App from './components/app.vue';
-export const initSavedReplies = () => {
+export const initCommentTemplates = () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
- const el = document.getElementById('js-saved-replies-root');
+ const el = document.getElementById('js-comment-templates-root');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/saved_replies/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue
index 94215389844..343efdccefa 100644
--- a/app/assets/javascripts/saved_replies/pages/edit.vue
+++ b/app/assets/javascripts/comment_templates/pages/edit.vue
@@ -32,7 +32,7 @@ export default {
},
}) {
if (!savedReply) {
- createAlert({ message: __('Unable to find saved reply') });
+ createAlert({ message: __('Unable to find comment template') });
this.redirectToRoot();
}
},
@@ -54,7 +54,7 @@ export default {
<template>
<div>
<h5 class="gl-mt-0 gl-font-lg">
- {{ __('Edit saved reply') }}
+ {{ __('Edit comment template') }}
</h5>
<gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" />
<create-form
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
index 3e96fc0714e..72a94dafc58 100644
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -53,7 +53,7 @@ export default {
<template>
<div>
<h5 class="gl-mt-0 gl-font-lg">
- {{ __('Add new saved reply') }}
+ {{ __('Add new comment template') }}
</h5>
<create-form @saved="refetchSavedReplies" />
<list
diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
index c4e632d0f16..c4e632d0f16 100644
--- a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
index 76571ba628c..76571ba628c 100644
--- a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
index 66f5f43af49..66f5f43af49 100644
--- a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql
+++ b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql
index d8e76b5e2a8..d8e76b5e2a8 100644
--- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
+++ b/app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql
diff --git a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
index 14a47d7bc9c..14a47d7bc9c 100644
--- a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql
+++ b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/comment_templates/routes.js
index 7687c6f335a..7687c6f335a 100644
--- a/app/assets/javascripts/saved_replies/routes.js
+++ b/app/assets/javascripts/comment_templates/routes.js
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index e5e23f2fb5e..17c9f55a8a0 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -1,10 +1,6 @@
import $ from 'jquery';
// bootstrap jQuery plugins
-import 'bootstrap/js/dist/alert';
-import 'bootstrap/js/dist/button';
-import 'bootstrap/js/dist/collapse';
-import 'bootstrap/js/dist/modal';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/tab';
diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js
index defc2cbe276..f43a2d5d8ff 100644
--- a/app/assets/javascripts/constants.js
+++ b/app/assets/javascripts/constants.js
@@ -1,6 +1,5 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
export const getModifierKey = (removeSuffix = false) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
const winKey = `Ctrl${removeSuffix ? '' : '+'}`;
return window.gl?.client?.isMac ? '⌘' : winKey;
};
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 06b80a65528..cef446c4cf8 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -35,9 +35,6 @@ export default {
);
},
},
- toggleLinkCommandParams: {
- href: '',
- },
};
</script>
<template>
@@ -122,8 +119,7 @@ export default {
data-testid="link"
content-type="link"
icon-name="link"
- editor-command="toggleLink"
- :editor-command-params="$options.toggleLinkCommandParams"
+ editor-command="editLink"
category="tertiary"
size="medium"
:label="__('Insert link')"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index a4713eb3275..a3065be3772 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -8,6 +8,7 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
+import { getMarkType, getMarkRange } from '@tiptap/core';
import Link from '../../extensions/link';
import EditorStateObserver from '../editor_state_observer.vue';
import BubbleMenu from './bubble_menu.vue';
@@ -31,12 +32,36 @@ export default {
return {
linkHref: undefined,
linkCanonicalSrc: undefined,
- linkTitle: undefined,
+ linkText: undefined,
isEditing: false,
};
},
methods: {
+ linkIsEmpty() {
+ return (
+ !this.linkCanonicalSrc &&
+ !this.linkHref &&
+ (!this.linkText || this.linkText === this.linkTextInDoc())
+ );
+ },
+
+ linkTextInDoc() {
+ const { state } = this.tiptapEditor;
+ const type = getMarkType(Link.name, state.schema);
+ let { selection: range } = state;
+ if (range.from === range.to) {
+ range =
+ getMarkRange(state.selection.$from, type) ||
+ getMarkRange(state.selection.$to, type) ||
+ {};
+ }
+
+ if (!range.from || !range.to) return '';
+
+ return state.doc.textBetween(range.from, range.to, ' ');
+ },
+
shouldShow() {
return this.tiptapEditor.isActive(Link.name);
},
@@ -52,31 +77,51 @@ export default {
this.isEditing = false;
this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc);
-
- if (!this.linkCanonicalSrc && !this.linkHref) {
- this.removeLink();
- }
},
cancelEditingLink() {
this.endEditingLink();
- this.updateLinkToState();
+
+ if (this.linkIsEmpty()) {
+ this.removeLink();
+ } else {
+ this.updateLinkToState();
+ }
},
async saveEditedLink() {
- if (!this.linkCanonicalSrc) {
+ const chain = this.tiptapEditor.chain().focus();
+
+ const attrs = {
+ href: this.linkCanonicalSrc,
+ canonicalSrc: this.linkCanonicalSrc,
+ };
+
+ // if nothing was entered by the user and the link is empty, remove it
+ // since we don't want to insert an empty link
+ if (this.linkIsEmpty()) {
this.removeLink();
- } else {
- this.tiptapEditor
- .chain()
- .focus()
+ return;
+ }
+
+ if (!this.linkText) {
+ this.linkText = this.linkCanonicalSrc;
+ }
+
+ // if link text was updated, insert a new link in the doc with the new text
+ if (this.linkTextInDoc() !== this.linkText) {
+ chain
.extendMarkRange(Link.name)
- .updateAttributes(Link.name, {
- href: this.linkCanonicalSrc,
- canonicalSrc: this.linkCanonicalSrc,
- title: this.linkTitle,
+ .setMeta('preventAutolink', true)
+ .insertContent({
+ marks: [{ type: Link.name, attrs }],
+ type: 'text',
+ text: this.linkText,
})
.run();
+ } else {
+ // if link text was not updated, just update the attributes
+ chain.updateAttributes(Link.name, attrs).run();
}
this.endEditingLink();
@@ -84,22 +129,27 @@ export default {
updateLinkToState() {
const editor = this.tiptapEditor;
-
- const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
+ const { href, canonicalSrc } = editor.getAttributes(Link.name);
+ const text = this.linkTextInDoc();
if (
canonicalSrc === this.linkCanonicalSrc &&
href === this.linkHref &&
- title === this.linkTitle
+ text === this.linkText
) {
return;
}
- this.linkTitle = title;
+ this.linkText = text;
this.linkHref = href;
this.linkCanonicalSrc = canonicalSrc || href;
+ },
- this.isEditing = !this.linkCanonicalSrc;
+ onTransaction({ transaction }) {
+ this.linkText = this.linkTextInDoc();
+ if (transaction.getMeta('creatingLink')) {
+ this.isEditing = true;
+ }
},
copyLinkHref() {
@@ -107,31 +157,49 @@ export default {
},
removeLink() {
- this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run();
+ const chain = this.tiptapEditor.chain().focus();
+ if (this.linkTextInDoc()) {
+ chain.unsetLink().run();
+ } else {
+ chain
+ .insertContent({
+ type: 'text',
+ text: ' ',
+ })
+ .extendMarkRange(Link.name)
+ .unsetLink()
+ .deleteSelection()
+ .run();
+ }
},
resetBubbleMenuState() {
- this.linkTitle = undefined;
+ this.linkText = undefined;
this.linkHref = undefined;
this.linkCanonicalSrc = undefined;
},
},
tippyOptions: {
placement: 'bottom',
+ appendTo: () => document.body,
},
};
</script>
<template>
- <bubble-menu
- data-testid="link-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuLink"
- :should-show="shouldShow"
- :tippy-options="$options.tippyOptions"
- @show="updateLinkToState"
- @hidden="resetBubbleMenuState"
+ <editor-state-observer
+ :debounce="0"
+ @transaction="onTransaction"
+ @selectionUpdate="updateLinkToState"
>
- <editor-state-observer @selectionUpdate="updateLinkToState">
+ <bubble-menu
+ data-testid="link-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuLink"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ @show="updateLinkToState"
+ @hidden="resetBubbleMenuState"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-link
v-gl-tooltip
@@ -178,12 +246,12 @@ export default {
/>
</gl-button-group>
<gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink">
+ <gl-form-group :label="__('Text')" label-for="link-text">
+ <gl-form-input id="link-text" v-model="linkText" data-testid="link-text" />
+ </gl-form-group>
<gl-form-group :label="__('URL')" label-for="link-href">
<gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" />
</gl-form-group>
- <gl-form-group :label="__('Title')" label-for="link-title">
- <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" />
- </gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink">
{{ __('Cancel') }}
@@ -193,6 +261,6 @@ export default {
</gl-button>
</div>
</gl-form>
- </editor-state-observer>
- </bubble-menu>
+ </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 9e08a257abf..f9d48708473 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -82,6 +82,16 @@ export default {
required: false,
default: true,
},
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -109,6 +119,8 @@ export default {
autofocus,
drawioEnabled,
editable,
+ enableAutocomplete,
+ autocompleteDataSources,
} = this;
// This is a non-reactive attribute intentionally since this is a complex object.
@@ -118,6 +130,8 @@ export default {
extensions,
serializerConfig,
drawioEnabled,
+ enableAutocomplete,
+ autocompleteDataSources,
tiptapOptions: {
autofocus,
editable,
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index ccb46e3b593..62f2113a8f4 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -16,14 +16,21 @@ const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEv
export default {
inject: ['tiptapEditor', 'eventHub'],
+ props: {
+ debounce: {
+ type: Number,
+ required: false,
+ default: 100,
+ },
+ },
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
- const eventHandler = debounce(
- (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
- 100,
- );
+ let eventHandler = (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params);
+ if (this.debounce) {
+ eventHandler = debounce(eventHandler, this.debounce);
+ }
this.tiptapEditor?.on(tiptapEvent, eventHandler);
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index a5be63fa89f..cd9fdeeca46 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,22 +1,17 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
-import ToolbarImageButton from './toolbar_image_button.vue';
-import ToolbarLinkButton from './toolbar_link_button.vue';
+import ToolbarAttachmentButton from './toolbar_attachment_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
- GlTabs,
- GlTab,
ToolbarButton,
ToolbarTextStyleDropdown,
- ToolbarLinkButton,
ToolbarTableButton,
- ToolbarImageButton,
+ ToolbarAttachmentButton,
ToolbarMoreDropdown,
},
methods: {
@@ -27,84 +22,87 @@ export default {
};
</script>
<template>
- <gl-tabs content-class="gl-display-none">
- <gl-tab title-link-class="gl-py-4 gl-px-3" :title="__('Write')" />
- <template #tabs-end>
- <div class="gl-ml-auto gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end">
- <toolbar-text-style-dropdown
- data-testid="text-styles"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- editor-command="toggleBold"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- editor-command="toggleItalic"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- editor-command="toggleCode"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-button
- data-testid="bullet-list"
- content-type="bulletList"
- icon-name="list-bulleted"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleBulletList"
- :label="__('Add a bullet list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="ordered-list"
- content-type="orderedList"
- icon-name="list-numbered"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleOrderedList"
- :label="__('Add a numbered list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="task-list"
- content-type="taskList"
- icon-name="list-task"
- class="gl-display-none gl-sm-display-inline"
- editor-command="toggleTaskList"
- :label="__('Add a checklist')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
- <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
- </div>
- </template>
- </gl-tabs>
+ <div
+ class="gl-w-full gl-border-b gl-display-flex gl-justify-content-end"
+ data-testid="formatting-toolbar"
+ >
+ <div class="gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end">
+ <toolbar-text-style-dropdown
+ data-testid="text-styles"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="__('Bold text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="__('Italic text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="__('Code')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="link"
+ content-type="link"
+ icon-name="link"
+ editor-command="editLink"
+ :label="__('Insert link')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleBulletList"
+ :label="__('Add a bullet list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="ordered-list"
+ content-type="orderedList"
+ icon-name="list-numbered"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleOrderedList"
+ :label="__('Add a numbered list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a checklist')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-attachment-button data-testid="attachment" @execute="trackToolbarControlExecution" />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
+ </div>
+ </div>
</template>
<style>
.gl-spinner-container {
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
index 37e6ef61d50..4074e50a706 100644
--- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui';
+import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
GlAvatarLabeled,
+ GlLoadingIcon,
},
props: {
@@ -32,6 +33,12 @@ export default {
type: Function,
required: true,
},
+
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
@@ -208,65 +215,75 @@ export default {
</script>
<template>
- <ul
- :class="{ show: items.length > 0 }"
- class="gl-dropdown dropdown-menu gl-relative"
- data-testid="content-editor-suggestions-dropdown"
- >
- <div class="gl-dropdown-inner gl-overflow-y-auto">
- <gl-dropdown-item
- v-for="(item, index) in items"
- ref="dropdownItems"
- :key="index"
- :class="{ 'gl-bg-gray-50': index === selectedIndex }"
- @click="selectItem(index)"
- >
- <gl-avatar-labeled
- v-if="isUser"
- :label="item.username"
- :sub-label="avatarSubLabel(item)"
- :src="item.avatar_url"
- :entity-name="item.username"
- :shape="item.type === 'Group' ? 'rect' : 'circle'"
- :size="32"
- />
- <span v-if="isIssue || isMergeRequest">
- <small>{{ item.iid }}</small>
- {{ item.title }}
- </span>
- <span v-if="isVulnerability || isSnippet">
- <small>{{ item.id }}</small>
- {{ item.title }}
- </span>
- <span v-if="isEpic">
- <small>{{ item.reference }}</small>
- {{ item.title }}
- </span>
- <span v-if="isMilestone">
- {{ item.title }}
- </span>
- <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
- <span
- data-testid="label-color-box"
- class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
- :style="{ backgroundColor: item.color }"
- ></span>
- {{ item.title }}
- </span>
- <span v-if="isCommand">
- /{{ item.name }} <small> {{ item.params[0] }} </small><br />
- <em>
- <small> {{ item.description }} </small>
- </em>
- </span>
- <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
- <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
- <div class="gl-flex-grow-1">
- {{ item.name }}<br />
- <small>{{ item.d }}</small>
+ <div>
+ <ul
+ v-if="!loading"
+ :class="{ show: items.length > 0 }"
+ class="gl-dropdown dropdown-menu gl-relative gl-m-0!"
+ data-testid="content-editor-suggestions-dropdown"
+ >
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <gl-dropdown-item
+ v-for="(item, index) in items"
+ ref="dropdownItems"
+ :key="index"
+ :class="{ 'gl-bg-gray-50': index === selectedIndex }"
+ @click="selectItem(index)"
+ >
+ <gl-avatar-labeled
+ v-if="isUser"
+ :label="item.username"
+ :sub-label="avatarSubLabel(item)"
+ :src="item.avatar_url"
+ :entity-name="item.username"
+ :shape="item.type === 'Group' ? 'rect' : 'circle'"
+ :size="32"
+ />
+ <span v-if="isIssue || isMergeRequest">
+ <small>{{ item.iid }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isVulnerability || isSnippet">
+ <small>{{ item.id }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isEpic">
+ <small>{{ item.reference }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isMilestone">
+ {{ item.title }}
+ </span>
+ <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
+ <span
+ data-testid="label-color-box"
+ class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
+ :style="{ backgroundColor: item.color }"
+ ></span>
+ {{ item.title }}
+ </span>
+ <span v-if="isCommand">
+ /{{ item.name }} <small> {{ item.params[0] }} </small><br />
+ <em>
+ <small> {{ item.description }} </small>
+ </em>
+ </span>
+ <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
+ <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
+ <div class="gl-flex-grow-1">
+ {{ item.name }}<br />
+ <small>{{ item.d }}</small>
+ </div>
</div>
+ </gl-dropdown-item>
+ </div>
+ </ul>
+ <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!">
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <div class="gl-px-5">
+ <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }}
</div>
- </gl-dropdown-item>
+ </div>
</div>
- </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
new file mode 100644
index 00000000000..efb9a5b07b5
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import Link from '../extensions/link';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ linkHref: '',
+ };
+ },
+ methods: {
+ emitExecute(source = 'url') {
+ this.$emit('execute', { contentType: Link.name, value: source });
+ },
+ openFileUpload() {
+ this.$refs.fileSelector.click();
+ },
+ onFileSelect(e) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .uploadAttachment({
+ file: e.target.files[0],
+ })
+ .run();
+
+ // Reset the file input so that the same file can be uploaded again
+ this.$refs.fileSelector.value = '';
+ this.emitExecute('upload');
+ },
+ },
+};
+</script>
+<template>
+ <span class="gl-display-inline-flex">
+ <gl-button
+ v-gl-tooltip
+ :text="__('Attach a file or image')"
+ :title="__('Attach a file or image')"
+ category="tertiary"
+ icon="paperclip"
+ lazy
+ @click="openFileUpload"
+ />
+ <input
+ ref="fileSelector"
+ type="file"
+ name="content_editor_image"
+ class="gl-display-none"
+ data-qa-selector="file_upload_field"
+ @change="onFileSelect"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
deleted file mode 100644
index 8ed4dfce6de..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import { acceptedMimes } from '../services/upload_helpers';
-import { extractFilename } from '../services/utils';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- imgSrc: '',
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- insertImage() {
- this.tiptapEditor
- .chain()
- .focus()
- .setImage({
- src: this.imgSrc,
- canonicalSrc: this.imgSrc,
- alt: extractFilename(this.imgSrc),
- })
- .run();
-
- this.resetFields();
- this.emitExecute();
- },
- emitExecute(source = 'url') {
- this.$emit('execute', { contentType: 'image', value: source });
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.emitExecute('upload');
- },
- },
- acceptedMimes: acceptedMimes.image,
-};
-</script>
-<template>
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :text="__('Insert image')"
- :title="__('Insert image')"
- size="small"
- category="tertiary"
- icon="media"
- lazy
- text-sr-only
- data-testid="insert-image-toolbar-button"
- @hidden="resetFields()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
- <template #append>
- <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="openFileUpload">
- {{ __('Upload image') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_image"
- :accept="$options.acceptedMimes"
- class="gl-display-none"
- data-qa-selector="file_upload_field"
- @change="onFileSelect"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
deleted file mode 100644
index 4fb1e8ce16f..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import Link from '../extensions/link';
-import { hasSelection } from '../services/utils';
-import EditorStateObserver from './editor_state_observer.vue';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- EditorStateObserver,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- linkHref: '',
- isActive: false,
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- updateLinkState({ editor }) {
- const { canonicalSrc, href } = editor.getAttributes(Link.name);
-
- this.isActive = editor.isActive(Link.name);
- this.linkHref = canonicalSrc || href;
- },
- updateLink() {
- this.tiptapEditor
- .chain()
- .focus()
- .unsetLink()
- .setLink({
- href: this.linkHref,
- canonicalSrc: this.linkHref,
- })
- .run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- selectLink() {
- const { tiptapEditor } = this;
-
- // a selection has already been made by the user, so do nothing
- if (!hasSelection(tiptapEditor)) {
- tiptapEditor.chain().focus().extendMarkRange(Link.name).run();
- }
- },
- removeLink() {
- this.tiptapEditor.chain().focus().unsetLink().run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.$emit('execute', { contentType: Link.name });
- },
- },
-};
-</script>
-<template>
- <editor-state-observer @transaction="updateLinkState">
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :title="__('Insert link')"
- :text="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- text-sr-only
- lazy
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="isActive" @click="removeLink">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-else @click="openFileUpload">
- {{ __('Upload file') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_attachment"
- class="gl-display-none"
- @change="onFileSelect"
- />
- </span>
- </editor-state-observer>
-</template>
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 4b1929e1a20..bf2740f9864 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -83,7 +83,7 @@ export default {
text-sr-only
lazy
>
- <gl-dropdown-form class="gl-px-3!">
+ <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)"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 81f9b1f0af5..55cf38dfcbb 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -80,7 +80,7 @@ export default {
<template>
<editor-state-observer @transaction="updateDiagramPreview">
<node-view-wrapper
- :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`"
+ :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`"
as="pre"
>
<div
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
new file mode 100644
index 00000000000..4126c65d87f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
@@ -0,0 +1,45 @@
+<script>
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLink,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return this.node.attrs.text;
+ },
+ isCommand() {
+ return this.node.attrs.referenceType === 'command';
+ },
+ isMember() {
+ return this.node.attrs.referenceType === 'user';
+ },
+ isCurrentUser() {
+ return gon.current_username === this.text.substring(1);
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span v-if="isCommand">{{ text }}</span>
+ <gl-link
+ v-else
+ href="#"
+ class="gfm"
+ :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }"
+ @click.prevent.stop
+ >{{ text }}</gl-link
+ >
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
index 4206c866032..4206c866032 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/label.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 6a3740a5952..490025a9ac6 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -12,6 +12,11 @@ export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
export const TEXT_STYLE_DROPDOWN_ITEMS = [
{
+ contentType: 'paragraph',
+ editorCommand: 'setParagraph',
+ label: __('Normal text'),
+ },
+ {
contentType: 'heading',
commandParams: { level: 1 },
editorCommand: 'setHeading',
@@ -35,11 +40,6 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
commandParams: { level: 4 },
label: __('Heading 4'),
},
- {
- contentType: 'paragraph',
- editorCommand: 'setParagraph',
- label: __('Normal text'),
- },
];
export const ALERT_EVENT = 'alert';
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index e985e561fda..314d5230b01 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -18,6 +18,8 @@ export const extractHrefFromMarkdownLink = (match) => {
};
export default Link.extend({
+ inclusive: false,
+
addOptions() {
return {
...this.parent?.(),
@@ -64,4 +66,18 @@ export default Link.extend({
},
};
},
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ editLink: (attrs) => ({ chain }) => {
+ chain().setMeta('creatingLink', true).setLink(attrs).run();
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-k': () => this.editor.commands.editLink(),
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 0a9a0d8d4c1..82fa5ce6c1d 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -37,8 +37,18 @@ export default Extension.create({
const { state, view } = editor;
const { tr, selection } = state;
+ const { firstChild } = document.content;
+ const content =
+ 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);
+ }
- tr.replaceWith(selection.from - 1, selection.to, document.content);
view.dispatch(tr);
})
.catch(() => {
@@ -53,13 +63,29 @@ export default Extension.create({
};
},
addProseMirrorPlugins() {
+ let pasteRaw = false;
+
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
- handlePaste: (_, event) => {
+ 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;
+
+ if (pasteRaw) {
+ tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to);
+ view.dispatch(tr);
+ return true;
+ }
+
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index ed343d8acf8..01ffc217894 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import { Node } from '@tiptap/core';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -44,7 +42,7 @@ export default Node.create({
parseHTML() {
return [
{
- tag: `.${this.options.mediaType}-container`,
+ tag: `.${this.options.mediaType}-container`, // eslint-disable-line @gitlab/require-i18n-strings
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 707beaf1231..b56aa8596a0 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,6 @@
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import ReferenceWrapper from '../components/wrappers/reference.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const getAnchor = (element) => {
@@ -49,7 +51,7 @@ export default Node.create({
];
},
- renderHTML({ node }) {
- return ['a', { href: '#' }, 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 9dff0b7a689..0441f8ef8d2 100644
--- a/app/assets/javascripts/content_editor/extensions/reference_label.js
+++ b/app/assets/javascripts/content_editor/extensions/reference_label.js
@@ -1,6 +1,6 @@
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants';
-import LabelWrapper from '../components/wrappers/label.vue';
+import LabelWrapper from '../components/wrappers/reference_label.vue';
import Reference from './reference';
export default Reference.extend({
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index eb53a3a61b3..e72b5c7365c 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -57,14 +57,25 @@ function createSuggestionPlugin({
let component;
let popup;
+ const onUpdate = (props) => {
+ component?.updateProps({ ...props, loading: false });
+
+ if (!props.clientRect) return;
+
+ popup?.[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ };
+
return {
- onStart: (props) => {
+ onBeforeStart: (props) => {
component = new VueRenderer(SuggestionsDropdown, {
propsData: {
...props,
char,
nodeType,
nodeProps,
+ loading: true,
},
editor: props.editor,
});
@@ -84,17 +95,8 @@ function createSuggestionPlugin({
});
},
- onUpdate(props) {
- component?.updateProps(props);
-
- if (!props.clientRect) {
- return;
- }
-
- popup?.[0].setProps({
- getReferenceClientRect: props.clientRect,
- });
- },
+ onStart: onUpdate,
+ onUpdate,
onKeyDown(props) {
if (props.event.key === 'Escape') {
@@ -118,12 +120,18 @@ function createSuggestionPlugin({
export default Node.create({
name: 'suggestions',
+ addOptions() {
+ return {
+ autocompleteDataSources: {},
+ };
+ },
+
addProseMirrorPlugins() {
return [
createSuggestionPlugin({
editor: this.editor,
char: '@',
- dataSource: gl.GfmAutoComplete?.dataSources.members,
+ dataSource: this.options.autocompleteDataSources.members,
nodeType: 'reference',
nodeProps: {
referenceType: 'user',
@@ -133,7 +141,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '#',
- dataSource: gl.GfmAutoComplete?.dataSources.issues,
+ dataSource: this.options.autocompleteDataSources.issues,
nodeType: 'reference',
nodeProps: {
referenceType: 'issue',
@@ -143,7 +151,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '$',
- dataSource: gl.GfmAutoComplete?.dataSources.snippets,
+ dataSource: this.options.autocompleteDataSources.snippets,
nodeType: 'reference',
nodeProps: {
referenceType: 'snippet',
@@ -153,7 +161,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '~',
- dataSource: gl.GfmAutoComplete?.dataSources.labels,
+ dataSource: this.options.autocompleteDataSources.labels,
nodeType: 'reference_label',
nodeProps: {
referenceType: 'label',
@@ -163,7 +171,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '&',
- dataSource: gl.GfmAutoComplete?.dataSources.epics,
+ dataSource: this.options.autocompleteDataSources.epics,
nodeType: 'reference',
nodeProps: {
referenceType: 'epic',
@@ -173,7 +181,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '[vulnerability:',
- dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities,
+ dataSource: this.options.autocompleteDataSources.vulnerabilities,
nodeType: 'reference',
nodeProps: {
referenceType: 'vulnerability',
@@ -183,7 +191,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '!',
- dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests,
+ dataSource: this.options.autocompleteDataSources.mergeRequests,
nodeType: 'reference',
nodeProps: {
referenceType: 'merge_request',
@@ -193,7 +201,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '%',
- dataSource: gl.GfmAutoComplete?.dataSources.milestones,
+ dataSource: this.options.autocompleteDataSources.milestones,
nodeType: 'reference',
nodeProps: {
referenceType: 'milestone',
@@ -203,7 +211,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '/',
- dataSource: gl.GfmAutoComplete?.dataSources.commands,
+ dataSource: this.options.autocompleteDataSources.commands,
nodeType: 'reference',
nodeProps: {
referenceType: 'command',
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 9d536793287..f1d4f85dcb0 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -88,6 +88,8 @@ export const createContentEditor = ({
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
drawioEnabled = false,
+ enableAutocomplete,
+ autocompleteDataSources = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -144,7 +146,6 @@ export const createContentEditor = ({
Sourcemap,
Strike,
Subscript,
- Suggestions,
Superscript,
TableCell,
TableHeader,
@@ -160,6 +161,7 @@ export const createContentEditor = ({
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+ if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources }));
if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown }));
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index e27a427372c..9ff50b45088 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -194,6 +195,7 @@ const defaultSerializerConfig = {
inline: true,
}),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
+ [Loading.name]: () => {},
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: renderReference,
@@ -227,6 +229,7 @@ const defaultSerializerConfig = {
[TableRow.name]: renderTableRow,
[TaskItem.name]: preserveUnchanged((state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ if (!node.textContent) state.write('&nbsp;');
state.renderContent(node);
}),
[TaskList.name]: preserveUnchanged((state, node) => {
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index fe1b32c5b0a..11a11ed43bd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => {
const range = getRangeFromSourcePos(element.dataset.sourcepos);
let elSource = '';
+ if (!source.length) return undefined;
+
for (let i = range.start.row; i <= range.end.row; i += 1) {
if (i === range.start.row) {
elSource += source[i].substring(range.start.col);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 540815f57c9..664473fccfe 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -309,12 +309,15 @@ export function renderHardBreak(state, node, parent, index) {
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs;
+ let realSrc = canonicalSrc || src || '';
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (realSrc.startsWith('data:')) realSrc = '';
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
const sourceExpression = isReference
? `[${canonicalSrc}]`
- : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
+ : `(${state.esc(realSrc)}${quotedTitle})`;
const sizeAttributes = [];
if (width) {
@@ -604,7 +607,7 @@ export const link = {
return '[';
}
- const attrs = { href: state.esc(href || canonicalSrc) };
+ const attrs = { href: state.esc(href || canonicalSrc || '') };
if (title) {
attrs.title = title;
@@ -620,14 +623,14 @@ export const link = {
const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
if (isReference) {
- return `][${state.esc(canonicalSrc || href)}]`;
+ return `][${state.esc(canonicalSrc || href || '')}]`;
}
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
}
- return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
+ return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`;
},
};
@@ -638,9 +641,8 @@ const generateStrikeTag = (wrapTagName = openTag) => {
switch (type) {
case '~~':
return type;
- /* eslint-disable @gitlab/require-i18n-strings */
- case '<del':
- case '<strike':
+ case '<del': // eslint-disable-line @gitlab/require-i18n-strings
+ case '<strike': // eslint-disable-line @gitlab/require-i18n-strings
case '<s':
return wrapTagName(type.substring(1));
default:
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index c9097b9384f..94f27dbf048 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -131,7 +131,7 @@ export default {
</dl>
</div>
</div>
- <div class="table-section section-30 section-wrap">
+ <div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
@@ -168,7 +168,7 @@ export default {
<span v-else class="text-secondary">{{ __('None') }}</span>
</div>
</div>
- <div class="table-section section-15 text-right">
+ <div class="table-section section-15">
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
@@ -176,7 +176,23 @@ export default {
</span>
</div>
</div>
- <div class="table-section section-15 table-button-footer deploy-key-actions">
+ <div class="table-section section-15">
+ <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div>
+ <div class="table-mobile-content text-secondary key-expires-at">
+ <span
+ v-if="deployKey.expires_at"
+ v-gl-tooltip
+ :title="tooltipTitle(deployKey.expires_at)"
+ data-testid="expires-at-tooltip"
+ >
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
+ </span>
+ <span v-else>
+ <span data-testid="expires-never">{{ __('Never') }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="table-section section-10 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }}
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 77ec1ef590f..e04cbbe72b9 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -34,10 +34,12 @@ export default {
<div role="rowheader" class="table-section section-40">
{{ s__('DeployKeys|Deploy key') }}
</div>
- <div role="rowheader" class="table-section section-30">
+ <div role="rowheader" class="table-section section-20">
{{ s__('DeployKeys|Project usage') }}
</div>
- <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div>
+ <!-- leave 10% space for actions --->
</div>
<deploy-key
v-for="deployKey in keys"
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
index 8ca4dc587a8..2cb9e9a56a3 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
import { debounce } from 'lodash';
@@ -59,6 +57,7 @@ export class GitLabDropdownFilter {
return BLUR_KEYCODES.indexOf(keyCode) !== -1;
}
+ // eslint-disable-next-line consistent-return
filter(searchText) {
let group;
let results;
@@ -114,9 +113,10 @@ export class GitLabDropdownFilter {
const matches = fuzzaldrinPlus.match($el.text().trim(), searchText);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
- return $el.show().removeClass('option-hidden');
+ $el.show().removeClass('option-hidden');
+ } else {
+ $el.hide().addClass('option-hidden');
}
- return $el.hide().addClass('option-hidden');
}
});
} else {
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 0008c3504ce..537c810bcff 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -1179,9 +1179,11 @@ export default class Notes {
const form = textarea.parents('form');
const reopenbtn = form.find('.js-note-target-reopen');
const closebtn = form.find('.js-note-target-close');
+ const savebtn = form.find('.js-comment-save-button');
const commentTypeComponent = form.get(0)?.commentTypeComponent;
if (textarea.val().trim().length > 0) {
+ savebtn.enable();
reopentext = reopenbtn.attr('data-alternative-text');
closetext = closebtn.attr('data-alternative-text');
if (reopenbtn.text() !== reopentext) {
@@ -1200,6 +1202,7 @@ export default class Notes {
commentTypeComponent.disabled = false;
}
} else {
+ savebtn.disable();
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
@@ -1395,7 +1398,7 @@ export default class Notes {
*/
static isNewNote(noteEntity, note_ids) {
if (note_ids.length === 0) {
- Notes.loadNotesIds(note_ids);
+ note_ids = Notes.getNotesIds();
}
const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1;
if (isNewEntry) {
@@ -1405,16 +1408,17 @@ export default class Notes {
}
/**
- * Load notes ids
+ * Get notes ids
*/
- static loadNotesIds(note_ids) {
- const $notesList = $('.main-notes-list li[id^=note_]');
- for (const $noteItem of $notesList) {
- if (Notes.isNodeTypeElement($noteItem)) {
- const noteId = parseInt($noteItem.id.split('_')[1], 10);
- note_ids.push(noteId);
- }
- }
+ static getNotesIds() {
+ /**
+ * The selector covers following notes
+ * - notes and thread below the snippets and commit page
+ * - notes on the file of commit page
+ * - notes on an image file of commit page
+ */
+ const notesList = [...document.querySelectorAll('.notes:not(.notes-form) li[id]')];
+ return notesList.map((noteItem) => parseInt(noteItem.dataset.noteId, 10));
}
/**
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index 80b146c9209..12bb4b830f8 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -14,7 +14,7 @@ export default () => {
issuePath,
registerPath,
signInPath,
- savedRepliesNewPath,
+ newCommentTemplatePath,
} = el.dataset;
const router = createRouter(issuePath);
@@ -39,7 +39,7 @@ export default () => {
issueIid,
registerPath,
signInPath,
- newSavedRepliesPath: savedRepliesNewPath,
+ newCommentTemplatePath,
},
mounted() {
performanceMarkAndMeasure({
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 0251ffe28f9..2f2b2ed1a90 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -332,7 +332,7 @@ export default {
<template>
<div
- class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
+ class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
<div
class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index ab003fb2879..e270613e4eb 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -104,7 +104,7 @@ export default {
return this.permissions.createDesign;
},
showToolbar() {
- return this.canCreateDesign && this.allVersions.length > 0;
+ return this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
@@ -375,6 +375,7 @@ export default {
<design-version-dropdown />
</div>
<div
+ v-if="canCreateDesign"
v-show="hasDesigns"
class="gl-display-flex gl-align-items-center"
data-testid="design-selector-toolbar"
@@ -489,7 +490,11 @@ export default {
/>
</li>
<template #header>
- <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
+ <li
+ v-if="canCreateDesign"
+ :class="designDropzoneWrapperClass"
+ data-testid="design-dropzone-wrapper"
+ >
<design-dropzone
:enable-drag-behavior="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 9ef0f336d43..1ae7b6a2110 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -1,8 +1,7 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import produce from 'immer';
import { differenceBy } from 'lodash';
import { createAlert } from '~/alert';
+import { TYPENAME_DISCUSSION, TYPENAME_TODO, TYPENAME_USER } from '~/graphql_shared/constants';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
@@ -60,7 +59,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
});
const newDiscussion = {
- __typename: 'Discussion',
+ __typename: TYPENAME_DISCUSSION,
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
@@ -86,7 +85,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
design.issue.participants.nodes = [
...design.issue.participants.nodes,
{
- __typename: 'User',
+ __typename: TYPENAME_USER,
...createImageDiffNote.note.author,
},
];
@@ -199,7 +198,7 @@ export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables)
const data = produce(sourceData, (draftData) => {
const design = extractDesign(draftData);
const existingTodos = design.currentUserTodos?.nodes || [];
- const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }];
+ const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: TYPENAME_TODO }];
if (!design.currentUserTodos) {
design.currentUserTodos = {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 9ccba88f7e6..9b3db78724d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -45,7 +45,9 @@ import {
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
+import { updateChangesTabCount } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
+import FindingsDrawer from './shared/findings_drawer.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import CommitWidget from './commit_widget.vue';
import CompareVersions from './compare_versions.vue';
@@ -59,6 +61,7 @@ import PreRenderer from './pre_renderer.vue';
export default {
name: 'DiffsApp',
components: {
+ FindingsDrawer,
DynamicScroller,
DynamicScrollerItem,
PreRenderer,
@@ -199,6 +202,7 @@ export default {
numTotalFiles: 'realSize',
numVisibleFiles: 'size',
}),
+ ...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
'showTreeList',
'isLoading',
@@ -233,6 +237,7 @@ export default {
'flatBlobsList',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
+ ...mapGetters('findingsDrawer', ['activeDrawer']),
diffs() {
if (!this.viewDiffsFileByFile) {
return this.diffFiles;
@@ -248,6 +253,9 @@ export default {
renderDiffFiles() {
return this.flatBlobsList.length > 0;
},
+ diffsIncomplete() {
+ return this.flatBlobsList.length !== this.diffFiles.length;
+ },
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
},
@@ -308,6 +316,11 @@ export default {
diffViewType() {
this.adjustView();
},
+ viewDiffsFileByFile(newViewFileByFile) {
+ if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) {
+ this.refetchDiffData({ refetchMeta: false });
+ }
+ },
shouldShow() {
// When the shouldShow property changed to true, the route is rendered for the first time
// and if we have the isLoading as true this means we didn't fetch the data
@@ -337,8 +350,6 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
- this.interfaceWithDOM();
-
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -426,41 +437,48 @@ export default {
'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
+ 'fetchFileByFile',
'fetchCoverageFiles',
'fetchCodequality',
+ 'rereadNoteHash',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
'cacheTreeListWidth',
- 'scrollToFile',
+ 'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
'setFileByFile',
'disableVirtualScroller',
]),
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ closeDrawer() {
+ this.setDrawer({});
+ },
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);
+ }
},
unsubscribeFromEvents() {
+ if (this.glFeatures.singleFileFileByFile) {
+ notesEventHub.$off('fetchedNotesData', this.rereadNoteHash);
+ diffsEventHub.$off('diffFilesModified', this.setDiscussions);
+ }
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
- interfaceWithDOM() {
- this.diffsTab = document.querySelector('.js-diffs-tab');
- },
- updateChangesTabCount() {
- const badge = this.diffsTab.querySelector('.gl-badge');
-
- if (this.diffsTab && badge) {
- badge.textContent = this.diffFilesLength;
- }
- },
navigateToDiffFileNumber(number) {
- this.navigateToDiffFileIndex(number - 1);
+ this.navigateToDiffFileIndex({
+ index: number - 1,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
},
- refetchDiffData() {
- this.fetchData(false);
+ refetchDiffData({ refetchMeta = true } = {}) {
+ this.fetchData({ toggleTree: false, fetchMeta: refetchMeta });
},
needsReload() {
return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]);
@@ -468,42 +486,52 @@ export default {
needsFirstLoad() {
return !this.diffFiles.length;
},
- fetchData(toggleTree = true) {
- this.fetchDiffFilesMeta()
- .then((data) => {
- let realSize = 0;
-
- if (data) {
- realSize = data.real_size;
- }
-
- this.diffFilesLength = parseInt(realSize, 10) || 0;
- if (toggleTree) {
- this.setTreeDisplay();
- }
-
- this.updateChangesTabCount();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ fetchData({ toggleTree = true, fetchMeta = true } = {}) {
+ if (fetchMeta) {
+ this.fetchDiffFilesMeta()
+ .then((data) => {
+ let realSize = 0;
+
+ if (data) {
+ realSize = data.real_size;
+
+ if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) {
+ this.fetchFileByFile();
+ }
+ }
+
+ this.diffFilesLength = parseInt(realSize, 10) || 0;
+ if (toggleTree) {
+ this.setTreeDisplay();
+ }
+
+ updateChangesTabCount({
+ count: this.diffFilesLength,
+ });
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
- this.fetchDiffFilesBatch()
- .then(() => {
- if (toggleTree) this.setTreeDisplay();
- // Guarantee the discussions are assigned after the batch finishes.
- // Just watching the length of the discussions or the diff files
- // isn't enough, because with split diff loading, neither will
- // change when loading the other half of the diff files.
- this.setDiscussions();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) {
+ this.fetchDiffFilesBatch()
+ .then(() => {
+ if (toggleTree) this.setTreeDisplay();
+ // Guarantee the discussions are assigned after the batch finishes.
+ // Just watching the length of the discussions or the diff files
+ // isn't enough, because with split diff loading, neither will
+ // change when loading the other half of the diff files.
+ this.setDiscussions();
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
if (this.endpointCoverage) {
this.fetchCoverageFiles();
@@ -579,7 +607,10 @@ export default {
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) {
- this.scrollToFile({ path: this.flatBlobsList[targetIndex].path });
+ this.goToFile({
+ path: this.flatBlobsList[targetIndex].path,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
}
},
setTreeDisplay() {
@@ -640,6 +671,11 @@ export default {
<template>
<div v-show="shouldShow">
+ <findings-drawer
+ v-if="glFeatures.codeQualityInlineDrawer"
+ :drawer="activeDrawer"
+ @close="closeDrawer"
+ />
<div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
<compare-versions :diff-files-count-text="numTotalFiles" />
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 1857ff557e6..d050f2fb9ae 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
@@ -30,6 +30,7 @@ export default {
CommitPipelineStatus,
GlButtonGroup,
GlButton,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -117,12 +118,11 @@ export default {
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
- <input
+ <gl-form-checkbox
v-if="isSelectable"
- class="gl-mr-3"
- type="checkbox"
:checked="checked"
- @change="$emit('handleCheckboxChange', $event.target.checked)"
+ class="gl-mt-3"
+ @change="$emit('handleCheckboxChange', !checked)"
/>
<user-avatar-link
:link-href="authorUrl"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index 5392c631c14..f3f05e3d9d9 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -1,27 +1,19 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { GlButton } from '@gitlab/ui';
import { NEW_CODE_QUALITY_FINDINGS } from '../i18n';
+import DiffCodeQualityItem from './diff_code_quality_item.vue';
export default {
i18n: {
newFindings: NEW_CODE_QUALITY_FINDINGS,
},
- components: { GlButton, GlIcon },
+ components: { GlButton, DiffCodeQualityItem },
props: {
codeQuality: {
type: Array,
required: true,
},
},
- methods: {
- severityClass(severity) {
- return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
- },
- severityIcon(severity) {
- return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
- },
- },
};
</script>
@@ -37,23 +29,11 @@ export default {
{{ $options.i18n.newFindings }}
</h4>
<ul class="gl-list-style-none gl-mb-0 gl-p-0">
- <li
+ <diff-code-quality-item
v-for="finding in codeQuality"
:key="finding.description"
- class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex"
- >
- <span class="gl-mr-3">
- <gl-icon
- :size="12"
- :name="severityIcon(finding.severity)"
- :class="severityClass(finding.severity)"
- class="codequality-severity-icon"
- />
- </span>
- <span>
- <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }}
- </span>
- </li>
+ :finding="finding"
+ />
</ul>
<gl-button
data-testid="diff-codequality-close"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
new file mode 100644
index 00000000000..eede110f46c
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: { GlLink, GlIcon },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ finding: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ toggleDrawer() {
+ this.setDrawer(this.finding);
+ },
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-1 gl-font-regular gl-display-flex">
+ <span class="gl-mr-3">
+ <gl-icon
+ :size="12"
+ :name="severityIcon(finding.severity)"
+ :class="severityClass(finding.severity)"
+ class="codequality-severity-icon"
+ />
+ </span>
+ <span
+ v-if="glFeatures.codeQualityInlineDrawer"
+ data-testid="description-button-section"
+ class="gl-display-flex"
+ >
+ <gl-link category="primary" variant="link" @click="toggleDrawer">
+ {{ finding.severity }} - {{ finding.description }}</gl-link
+ >
+ </span>
+ <span v-else data-testid="description-plain-text" class="gl-display-flex">
+ {{ finding.severity }} - {{ finding.description }}
+ </span>
+ </li>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index c19174dda8a..a58178eaef7 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -209,7 +209,11 @@ export default {
if (this.hasDiff) {
this.postRender();
- } else if (this.viewDiffsFileByFile && !this.isCollapsed) {
+ } else if (
+ this.viewDiffsFileByFile &&
+ !this.isCollapsed &&
+ !this.glFeatures.singleFileFileByFile
+ ) {
this.requestDiff();
}
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 16f45c3ad6a..c3a4897ce78 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -50,6 +50,7 @@ export default {
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
+ fileModeTooltip: __('File permissions'),
},
props: {
discussionPath: {
@@ -201,6 +202,9 @@ export default {
externalUrlLabel() {
return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
},
+ labelToggleFile() {
+ return this.expanded ? __('Hide file contents') : __('Show file contents');
+ },
},
watch: {
'idState.moreActionsShown': {
@@ -287,12 +291,14 @@ export default {
@click.self="handleToggleFile"
>
<div class="file-header-content">
- <gl-icon
+ <gl-button
v-if="collapsible"
- ref="collapseIcon"
- :name="collapseIcon"
- :size="16"
- class="diff-toggle-caret gl-mr-2"
+ ref="collapseButton"
+ class="gl-mr-2"
+ category="tertiary"
+ size="small"
+ :icon="collapseIcon"
+ :aria-label="labelToggleFile"
@click.stop="handleToggleFile"
/>
<a
@@ -342,7 +348,13 @@ export default {
data-track-property="diff_copy_file"
/>
- <small v-if="isModeChanged" ref="fileMode" class="mr-1">
+ <small
+ v-if="isModeChanged"
+ ref="fileMode"
+ v-gl-tooltip.hover
+ class="mr-1"
+ :title="$options.i18n.fileModeTooltip"
+ >
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -364,7 +376,7 @@ export default {
v-if="isReviewable && showLocalFileReviews"
v-gl-tooltip.hover
data-testid="fileReviewCheckbox"
- class="gl-mr-5 gl-display-flex gl-align-items-center"
+ class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
:checked="reviewed"
@change="toggleReview"
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 f63ab1bb067..43ba527dad8 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -8,7 +8,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
import NoteForm from '~/notes/components/note_form.vue';
-import autosave from '~/notes/mixins/autosave';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import {
DIFF_NOTE_TYPE,
INLINE_DIFF_LINES_KEY,
@@ -21,7 +21,7 @@ export default {
NoteForm,
MultilineCommentForm,
},
- mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
+ mixins: [diffLineNoteFormMixin, glFeatureFlagsMixin()],
props: {
diffFileHash: {
type: String,
@@ -146,6 +146,27 @@ export default {
return lines;
},
+ autosaveKey() {
+ if (!this.isLoggedIn) return '';
+
+ const {
+ id,
+ noteable_type: noteableTypeUnderscored,
+ noteableType,
+ diff_head_sha: diffHeadSha,
+ source_project_id: sourceProjectId,
+ } = this.noteableData;
+
+ return [
+ s__('Autosave|Note'),
+ capitalizeFirstCharacter(noteableTypeUnderscored || noteableType),
+ id,
+ diffHeadSha,
+ DIFF_NOTE_TYPE,
+ sourceProjectId,
+ this.line.line_code,
+ ].join('/');
+ },
},
created() {
if (this.range) {
@@ -155,17 +176,6 @@ export default {
}
},
mounted() {
- if (this.isLoggedIn) {
- const keys = [
- this.noteableData.diff_head_sha,
- DIFF_NOTE_TYPE,
- this.noteableData.source_project_id,
- this.line.line_code,
- ];
-
- this.initAutoSave(this.noteableData, keys);
- }
-
if (this.selectedCommentPosition) {
this.commentLineStart = this.selectedCommentPosition.start;
}
@@ -196,9 +206,6 @@ export default {
lineCode: this.line.line_code,
fileHash: this.diffFileHash,
});
- this.$nextTick(() => {
- this.resetAutoSave();
- });
}),
handleSaveNote(note) {
return this.saveDiffDiscussion({ note, formData: this.formData }).then(() =>
@@ -232,6 +239,7 @@ export default {
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
:save-button-title="__('Comment')"
+ :autosave-key="autosaveKey"
class="diff-comment-form gl-mt-3"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index e8b4ff16aec..7de8eff7863 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -76,7 +76,7 @@ export default {
class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center"
:class="{ bold: isCompareVersionsHeader }"
>
- <span>-</span>
+ <span>−</span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index a2e052e0f93..348d6d1d78d 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,7 +1,6 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
@@ -21,11 +20,7 @@ export default {
DiffCommentCell,
DraftNote,
},
- mixins: [
- draftCommentsMixin,
- IdState({ idProp: (vm) => vm.diffFile.file_hash }),
- glFeatureFlagsMixin(),
- ],
+ mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
props: {
diffFile: {
type: Object,
@@ -265,10 +260,7 @@ export default {
@stopdragging="onStopDragging"
/>
<diff-line
- v-if="
- glFeatures.refactorCodeQualityInlineFindings &&
- codeQualityExpandedLines.includes(getCodeQualityLine(line))
- "
+ v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:key="line.line_code"
:line="line"
@hideCodeQualityFindings="hideCodeQualityFindings"
diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index f6a8c679f3b..26d37484541 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,17 +1,18 @@
<script>
-import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
export const i18n = {
- title: __('Too many changes to show.'),
+ title: __('Some changes are not shown.'),
plainDiff: __('Plain diff'),
- emailPatch: __('Email patch'),
+ emailPatch: __('Patches'),
};
export default {
i18n,
components: {
GlAlert,
+ GlButton,
GlSprintf,
},
props: {
@@ -38,18 +39,15 @@ export default {
<template>
<gl-alert
variant="warning"
+ class="gl-mx-5 gl-mb-4 gl-mt-3"
:title="$options.i18n.title"
- :primary-button-text="$options.i18n.plainDiff"
- :primary-button-link="plainDiffPath"
- :secondary-button-text="$options.i18n.emailPatch"
- :secondary-button-link="emailPatchPath"
:dismissible="false"
>
<gl-sprintf
:message="
sprintf(
__(
- 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.',
+ 'For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes.',
),
{ visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */,
)
@@ -59,5 +57,13 @@ export default {
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
+ <template #actions>
+ <gl-button :href="plainDiffPath" class="gl-mr-3 gl-alert-action">
+ {{ $options.i18n.plainDiff }}
+ </gl-button>
+ <gl-button :href="emailPatchPath" class="gl-alert-action">
+ {{ $options.i18n.emailPatch }}
+ </gl-button>
+ </template>
</gl-alert>
</template>
diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
new file mode 100644
index 00000000000..da880c6f3ca
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { s__ } from '~/locale';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+
+export const i18n = {
+ severity: s__('FindingsDrawer|Severity:'),
+ engine: s__('FindingsDrawer|Engine:'),
+ category: s__('FindingsDrawer|Category:'),
+ otherLocations: s__('FindingsDrawer|Other locations:'),
+};
+
+export default {
+ i18n,
+ components: { GlDrawer, GlIcon, GlLink },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ drawer: {
+ type: Object,
+ required: true,
+ },
+ },
+ safeHtmlConfig: {
+ ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'],
+ ALLOWED_ATTR: ['href', 'rel'],
+ },
+ computed: {
+ drawerOffsetTop() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ },
+ DRAWER_Z_INDEX,
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ :header-height="drawerOffsetTop"
+ :z-index="$options.DRAWER_Z_INDEX"
+ class="findings-drawer"
+ :open="Object.keys(drawer).length !== 0"
+ @close="$emit('close')"
+ >
+ <template #title>
+ <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0">
+ {{ drawer.description }}
+ </h2>
+ </template>
+
+ <template #default>
+ <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!">
+ <li data-testid="findings-drawer-severity" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span>
+ <gl-icon
+ data-testid="findings-drawer-severity-icon"
+ :size="12"
+ :name="severityIcon(drawer.severity)"
+ :class="severityClass(drawer.severity)"
+ class="codequality-severity-icon"
+ />
+
+ {{ drawer.severity }}
+ </li>
+ <li data-testid="findings-drawer-engine" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span>
+ {{ drawer.engineName }}
+ </li>
+ <li data-testid="findings-drawer-category" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span>
+ {{ drawer.categories ? drawer.categories[0] : '' }}
+ </li>
+ <li data-testid="findings-drawer-other-locations" class="gl-mb-4">
+ <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{
+ $options.i18n.otherLocations
+ }}</span>
+ <ul class="gl-pl-6">
+ <li
+ v-for="otherLocation in drawer.otherLocations"
+ :key="otherLocation.path"
+ class="gl-mb-1"
+ >
+ <gl-link
+ data-testid="findings-drawer-other-locations-link"
+ :href="otherLocation.href"
+ >{{ otherLocation.path }}</gl-link
+ >
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''"
+ data-testid="findings-drawer-body"
+ class="drawer-body gl-display-block gl-px-3 gl-py-0!"
+ ></span>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index ab08c72b08f..4f1875e9175 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -5,6 +5,7 @@ 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 DiffFileRow from './diff_file_row.vue';
@@ -19,6 +20,7 @@ export default {
DiffFileRow,
RecycleScroller,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
hideFileStats: {
type: Boolean,
@@ -105,7 +107,7 @@ export default {
this.resizeObserver.disconnect();
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
+ ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
clearSearch() {
this.search = '';
},
@@ -128,7 +130,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="position-absolute tree-list-icon" />
+ <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
id="diff-tree-search"
@@ -154,7 +156,7 @@ export default {
<div
ref="scrollRoot"
:class="{ 'tree-list-blobs': !renderTreeList || search }"
- class="gl-flex-grow-1"
+ class="gl-flex-grow-1 mr-tree-list"
>
<recycle-scroller
v-if="flatFilteredTreeList.length"
@@ -172,10 +174,10 @@ export default {
:hide-file-stats="hideFileStats"
:current-diff-file-id="currentDiffFileId"
:style="{ '--level': item.level }"
- :class="{ 'tree-list-parent': item.tree.length }"
+ :class="{ 'tree-list-parent': item.level > 0 }"
class="gl-relative"
@toggleTreeOpen="toggleTreeOpen"
- @clickFile="(path) => scrollToFile({ path })"
+ @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, path })"
/>
</template>
<template #after>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 873c4819669..a459def6b4b 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -112,3 +112,6 @@ export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace';
export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting';
export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file';
export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files';
+
+// UI
+export const ZERO_CHANGES_ALT_DISPLAY = '-';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 0f44eb06cb3..e233a0cef0a 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -1,6 +1,12 @@
import { __, s__ } from '~/locale';
export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
+export const LOAD_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.",
+);
+export const DISCUSSION_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the single file diff for the discussion. Please reload this page.",
+);
export const DIFF_FILE_HEADER = {
optionsDropdownTitle: __('Options'),
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 00a08434dac..ad7182024da 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -27,7 +27,7 @@ export default function initDiffsApp(store = notesStore) {
store,
apolloProvider,
provide: {
- newSavedRepliesPath: dataset.savedRepliesNewPath,
+ newCommentTemplatePath: dataset.newCommentTemplatePath,
},
data() {
return {
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 9236e14beb1..a70c907314b 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -16,6 +16,7 @@ import { __, s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
import { sortTree } from '~/ide/stores/utils';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -49,6 +50,7 @@ import {
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
+import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n';
import eventHub from '../event_hub';
import { isCollapsed } from '../utils/diff_file';
import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews';
@@ -62,6 +64,8 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareLineForRenamedFile,
+ parseUrlHashAsFileHash,
+ isUrlHashNoteLink,
} from './utils';
export const setBaseConfig = ({ commit }, options) => {
@@ -101,6 +105,47 @@ export const setBaseConfig = ({ commit }, options) => {
});
};
+export const fetchFileByFile = async ({ state, getters, commit }) => {
+ const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
+ const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId);
+ const treeEntry = id
+ ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id)
+ : getters.flatBlobsList[0];
+
+ if (treeEntry && !treeEntry.diffLoaded && !getters.getDiffFileByHash(id)) {
+ // Overloading "batch" loading indicators so the UI stays mostly the same
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
+ commit(types.SET_RETRIEVING_BATCHES, true);
+
+ const urlParams = {
+ old_path: treeEntry.filePaths.old,
+ new_path: treeEntry.filePaths.new,
+ w: state.showWhitespace ? '0' : '1',
+ view: 'inline',
+ };
+
+ axios
+ .get(mergeUrlParams({ ...urlParams }, state.endpointDiffForPath))
+ .then(({ data: diffData }) => {
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files });
+
+ if (!isNoteLink && !state.currentDiffFileId) {
+ commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0]?.file_hash || '');
+ }
+
+ commit(types.SET_BATCH_LOADING_STATE, 'loaded');
+
+ eventHub.$emit('diffFilesModified');
+ })
+ .catch(() => {
+ commit(types.SET_BATCH_LOADING_STATE, 'error');
+ })
+ .finally(() => {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ });
+ }
+};
+
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
let perPage = state.viewDiffsFileByFile ? 1 : 5;
let increaseAmount = 1.4;
@@ -512,13 +557,20 @@ export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
}
};
-export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
+export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
note,
...formData,
});
+ if (containsSensitiveToken(note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return null;
+ }
+ }
+
return dispatch('saveNote', postData, { root: true })
.then((result) => dispatch('updateDiscussion', result.discussion, { root: true }))
.then((discussion) => dispatch('assignDiscussionsToDiff', [discussion]))
@@ -539,6 +591,31 @@ 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) {
+ dispatch('scrollToFile', { path });
+ } else {
+ if (!state.treeEntries[path]) return;
+
+ const { fileHash } = state.treeEntries[path];
+
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+ document.location.hash = fileHash;
+
+ if (!getters.isTreePathLoaded(path)) {
+ dispatch('fetchFileByFile')
+ .then(() => {
+ dispatch('scrollToFile', { path });
+ })
+ .catch(() => {
+ createAlert({
+ message: LOAD_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+ }
+};
+
export const scrollToFile = ({ state, commit, getters }, { path }) => {
if (!state.treeEntries[path]) return;
@@ -779,13 +856,11 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
});
export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) {
- /* eslint-disable @gitlab/require-i18n-strings */
if (!commitId) {
return Promise.reject(new Error('`commitId` is a required argument'));
} else if (!state.commit) {
- return Promise.reject(new Error('`state` must already contain a valid `commit`'));
+ return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings
}
- /* eslint-enable @gitlab/require-i18n-strings */
// this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421
const commitRE = new RegExp(state.commit.id, 'g');
@@ -821,6 +896,24 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
+export const rereadNoteHash = ({ state, dispatch }) => {
+ const urlHash = window?.location?.hash;
+
+ if (isUrlHashNoteLink(urlHash)) {
+ dispatch('setCurrentDiffFileIdFromNote', urlHash.split('_').pop())
+ .then(() => {
+ if (state.viewDiffsFileByFile) {
+ dispatch('fetchFileByFile');
+ }
+ })
+ .catch(() => {
+ createAlert({
+ message: DISCUSSION_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+};
+
export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, noteId) => {
const note = rootGetters.notesById[noteId];
@@ -833,11 +926,18 @@ export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, n
}
};
-export const navigateToDiffFileIndex = ({ commit, getters }, index) => {
+export const navigateToDiffFileIndex = (
+ { state, getters, commit, dispatch },
+ { index, singleFile },
+) => {
const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+
+ if (state.viewDiffsFileByFile && singleFile) {
+ dispatch('fetchFileByFile');
+ }
};
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 3739ef0cd55..4ca353333b7 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -559,19 +559,19 @@ export const allDiscussionWrappersExpanded = (diff) => {
return discussionsExpanded;
};
-export function isUrlHashNoteLink(urlHash) {
+export function isUrlHashNoteLink(urlHash = '') {
const id = urlHash.replace(/^#/, '');
return id.startsWith('note');
}
-export function isUrlHashFileHeader(urlHash) {
+export function isUrlHashFileHeader(urlHash = '') {
const id = urlHash.replace(/^#/, '');
return id.startsWith('diff-content');
}
-export function parseUrlHashAsFileHash(urlHash, currentDiffFileId = '') {
+export function parseUrlHashAsFileHash(urlHash = '', currentDiffFileId = '') {
const isNoteLink = isUrlHashNoteLink(urlHash);
let id = urlHash.replace(/^#/, '');
diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js
index 43e04a814c5..6847b8900d2 100644
--- a/app/assets/javascripts/diffs/utils/merge_request.js
+++ b/app/assets/javascripts/diffs/utils/merge_request.js
@@ -1,3 +1,5 @@
+import { ZERO_CHANGES_ALT_DISPLAY } from '../constants';
+
const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i;
function getVersionInfo({ endpoint } = {}) {
@@ -13,6 +15,17 @@ function getVersionInfo({ endpoint } = {}) {
};
}
+export function updateChangesTabCount({
+ count,
+ badge = document.querySelector('.js-diffs-tab .gl-badge'),
+} = {}) {
+ if (badge) {
+ // The purpose of this function is to assign to this parameter
+ /* eslint-disable-next-line no-param-reassign */
+ badge.textContent = count || ZERO_CHANGES_ALT_DISPLAY;
+ }
+}
+
export function getDerivedMergeRequestInformation({ endpoint } = {}) {
let mrPath;
let userOrGroup;
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
index 67b909d37c3..0afee7bebe0 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -52,7 +52,7 @@ export default {
<section
v-if="isVisible"
id="se-toolbar"
- class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-align-items-center"
+ class="gl-py-3 gl-px-5 gl-bg-white gl-border-b gl-display-flex gl-align-items-center"
>
<gl-button-group v-if="hasGroupItems($options.groups.file)">
<source-editor-toolbar-button
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 f8ff533f53f..9ec1a97ba1a 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
@@ -37,8 +37,6 @@ const setupDomElement = ({ injectToEl = null } = {}) => {
return previewEl;
};
-let dimResize = false;
-
export class EditorMarkdownPreviewExtension {
static get extensionName() {
return 'EditorMarkdownPreview';
@@ -53,7 +51,6 @@ export class EditorMarkdownPreviewExtension {
},
shown: false,
modelChangeListener: undefined,
- layoutChangeListener: undefined,
path: setupOptions.previewMarkdownPath,
actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true),
eventEmitter: new Emitter(),
@@ -65,13 +62,17 @@ export class EditorMarkdownPreviewExtension {
this.setupToolbar(instance);
}
- this.preview.layoutChangeListener = instance.onDidLayoutChange(() => {
- if (instance.markdownPreview?.shown && !dimResize) {
- const { width } = instance.getLayoutInfo();
- const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ const debouncedResizeHandler = debounce((entries) => {
+ for (const entry of entries) {
+ const { width: newInstanceWidth } = entry.contentRect;
+ if (instance.markdownPreview?.shown) {
+ const newWidth = newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ }
}
- });
+ }, 50);
+
+ this.resizeObserver = new ResizeObserver(debouncedResizeHandler);
this.preview.eventEmitter.event(this.togglePreview.bind(this, instance));
}
@@ -85,9 +86,7 @@ export class EditorMarkdownPreviewExtension {
}
cleanup(instance) {
- if (this.preview.layoutChangeListener) {
- this.preview.layoutChangeListener.dispose();
- }
+ this.resizeObserver.disconnect();
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
@@ -102,11 +101,7 @@ export class EditorMarkdownPreviewExtension {
static resizePreviewLayout(instance, width) {
const { height } = instance.getLayoutInfo();
- dimResize = true;
instance.layout({ width, height });
- window.requestAnimationFrame(() => {
- dimResize = false;
- });
}
setupToolbar(instance) {
@@ -130,9 +125,16 @@ export class EditorMarkdownPreviewExtension {
togglePreviewLayout(instance) {
const { width } = instance.getLayoutInfo();
- const newWidth = this.preview.shown
- ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
- : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ let newWidth;
+ if (this.preview.shown) {
+ // This means the preview is to be closed at the next step
+ newWidth = width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.disconnect();
+ } else {
+ // The preview is hidden, but is in the process to be opened
+ newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.observe(instance.getContainerDomNode());
+ }
EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index a5080332b78..44944a4a205 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -1894,6 +1894,10 @@
}
},
"additionalProperties": false
+ },
+ "publish": {
+ "description": "A path to a directory that contains the files to be published with Pages",
+ "type": "string"
}
},
"oneOf": [
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index b9392fabcbd..4484bc03737 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -232,8 +232,8 @@ export function emojiImageTag(name, src) {
title: `:${name}:`,
alt: `:${name}:`,
src,
- width: '20',
- height: '20',
+ width: '16',
+ height: '16',
align: 'absmiddle',
});
diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js
index 308077f98b1..6e88a998096 100644
--- a/app/assets/javascripts/entrypoints/super_sidebar.js
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -1,5 +1,6 @@
import '~/webpack';
import '~/commons';
-import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle';
+import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle';
initSuperSidebar();
+initSuperSidebarToggle();
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 53a93bbce30..9db3011ba5d 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -75,7 +75,12 @@ export default {
if (this.hasMultipleCommits) {
if (this.graphql) {
const { lastDeployment } = this.environment;
- return this.commitData(lastDeployment, 'commitPath');
+ return (
+ // data shape comming from REST and GraphQL is unfortunately different
+ // once we fully migrate to GraphQL it could be streamlined
+ this.commitData(lastDeployment, 'commitPath') ||
+ this.commitData(lastDeployment, 'webUrl')
+ );
}
const { last_deployment } = this.environment;
@@ -135,7 +140,6 @@ export default {
csrf,
cancelProps: {
text: __('Cancel'),
- attributes: { variant: 'danger' },
},
docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
@@ -157,7 +161,7 @@ export default {
}}</gl-link>
</template>
<template #docs="{ content }">
- <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="$options.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-modal>
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 31bc462f0b9..b2843b79ba6 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -158,7 +158,7 @@ export default {
>{{ instanceTitle }} ({{ instanceCount }})</span
>
<span ref="legend-icon" data-testid="legend-tooltip-target">
- <gl-icon class="gl-text-blue-500 gl-ml-2" name="question" />
+ <gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" />
</span>
<gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body">
<div class="deploy-board-legend gl-display-flex gl-flex-direction-column">
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 74eef50ebaf..d49598d2f21 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { formatTime } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
@@ -7,12 +7,9 @@ import eventHub from '../event_hub';
import actionMutation from '../graphql/mutations/action.mutation.graphql';
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
GlIcon,
},
props: {
@@ -36,6 +33,16 @@ export default {
title() {
return __('Deploy to...');
},
+ actionItems() {
+ return this.actions.map((actionItem) => ({
+ text: actionItem.name,
+ action: () => this.onClickAction(actionItem),
+ extraAttrs: {
+ disabled: this.isActionDisabled(actionItem),
+ },
+ ...actionItem,
+ }));
+ },
},
methods: {
async onClickAction(action) {
@@ -48,7 +55,6 @@ export default {
);
const confirmed = await confirmAction(confirmationMessage);
-
if (!confirmed) {
return;
}
@@ -80,30 +86,31 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
+ <gl-disclosure-dropdown
:text="title"
:title="title"
:loading="isLoading"
:aria-label="title"
+ :items="actionItems"
icon="play"
text-sr-only
right
data-container="body"
data-testid="environment-actions-button"
>
- <gl-dropdown-item
- v-for="(action, i) in actions"
- :key="i"
- :disabled="isActionDisabled(action)"
+ <gl-disclosure-dropdown-item
+ v-for="item in actionItems"
+ :key="item.name"
+ :item="item"
data-testid="manual-action-link"
- @click="onClickAction(action)"
>
- <span class="gl-flex-grow-1">{{ action.name }}</span>
- <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right">
- <gl-icon name="clock" />
- {{ remainingTime(action) }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item>
+ <span class="gl-flex-grow-1">{{ item.text }}</span>
+ <span v-if="item.scheduledAt" class="gl-text-gray-500 float-right">
+ <gl-icon name="clock" />
+ {{ remainingTime(item) }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
index cfb18cc4f82..736eaa7062d 100644
--- a/app/assets/javascripts/environments/components/kubernetes_overview.vue
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -1,14 +1,20 @@
<script>
-import { GlCollapse, GlButton } from '@gitlab/ui';
+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 KubernetesAgentInfo from './kubernetes_agent_info.vue';
+import KubernetesPods from './kubernetes_pods.vue';
export default {
components: {
GlCollapse,
GlButton,
+ GlAlert,
KubernetesAgentInfo,
+ KubernetesPods,
},
+ inject: ['kasTunnelUrl'],
props: {
agentName: {
required: true,
@@ -22,10 +28,16 @@ export default {
required: true,
type: String,
},
+ namespace: {
+ required: false,
+ type: String,
+ default: '',
+ },
},
data() {
return {
isVisible: false,
+ error: '',
};
},
computed: {
@@ -35,11 +47,26 @@ export default {
label() {
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();
+ },
+ k8sAccessConfiguration() {
+ return {
+ basePath: this.kasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers },
+ },
+ };
+ },
},
methods: {
toggleCollapse() {
this.isVisible = !this.isVisible;
},
+ onClusterError(message) {
+ this.error = message;
+ },
},
i18n: {
collapse: __('Collapse'),
@@ -66,7 +93,17 @@ export default {
:agent-name="agentName"
:agent-id="agentId"
:agent-project-path="agentProjectPath"
+ class="gl-mb-5" />
+
+ <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
+ {{ error }}
+ </gl-alert>
+
+ <kubernetes-pods
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
class="gl-mb-5"
+ @cluster-error="onClusterError"
/></template>
</gl-collapse>
</div>
diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue
new file mode 100644
index 00000000000..a153331ee58
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue
@@ -0,0 +1,111 @@
+<script>
+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';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlSingleStat,
+ },
+ apollo: {
+ k8sPods: {
+ query: k8sPodsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ update(data) {
+ return data?.k8sPods || [];
+ },
+ error(error) {
+ this.error = error;
+ this.$emit('cluster-error', this.error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ error: '',
+ };
+ },
+
+ computed: {
+ podStats() {
+ if (!this.k8sPods) return null;
+
+ return [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Running'),
+ title: this.$options.i18n.runningPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Pending'),
+ title: this.$options.i18n.pendingPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Succeeded'),
+ title: this.$options.i18n.succeededPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Failed'),
+ title: this.$options.i18n.failedPods,
+ },
+ ];
+ },
+ loading() {
+ return this.$apollo.queries.k8sPods.loading;
+ },
+ },
+ methods: {
+ getPodsByPhase(phase) {
+ const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase);
+ return filteredPods.length;
+ },
+ },
+ i18n: {
+ podsTitle: s__('Environment|Pods'),
+ runningPods: s__('Environment|Running'),
+ pendingPods: s__('Environment|Pending'),
+ succeededPods: s__('Environment|Succeeded'),
+ failedPods: s__('Environment|Failed'),
+ },
+};
+</script>
+<template>
+ <div>
+ <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p>
+
+ <gl-loading-icon v-if="loading" />
+
+ <div
+ v-else-if="podStats && !error"
+ class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3"
+ >
+ <gl-single-stat
+ v-for="(stat, index) in podStats"
+ :key="index"
+ class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3"
+ :value="stat.value"
+ :title="stat.title"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 2ec6e12b8b3..ee197bbcd45 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -148,6 +148,9 @@ export default {
return now < autoStopDate;
},
+ upcomingDeploymentIid() {
+ return this.environment.upcomingDeployment?.iid.toString() || '';
+ },
autoStopPath() {
return this.environment?.cancelAutoStopPath ?? '';
},
@@ -173,7 +176,8 @@ export default {
return this.glFeatures?.kasUserAccessProject;
},
hasRequiredAgentData() {
- return this.agent.project && this.agent.id && this.agent.name;
+ const { project, id, name } = this.agent || {};
+ return project && id && name;
},
showKubernetesOverview() {
return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
@@ -223,7 +227,7 @@ export default {
:icon="icon"
:aria-label="label"
size="small"
- category="tertiary"
+ category="secondary"
@click="toggleCollapse"
/>
<gl-link
@@ -270,7 +274,6 @@ export default {
<stop-component
v-if="canStop"
:environment="environment"
- class="gl-z-index-2"
data-track-action="click_button"
data-track-label="environment_stop"
graphql
@@ -351,7 +354,11 @@ export default {
class="gl-pl-4"
>
<template #approval>
- <environment-approval :environment="environment" @change="$emit('change')" />
+ <environment-approval
+ :deployment-iid="upcomingDeploymentIid"
+ :environment="environment"
+ @change="$emit('change')"
+ />
</template>
</deployment>
</div>
@@ -368,6 +375,7 @@ export default {
:agent-project-path="agent.project"
:agent-name="agent.name"
:agent-id="agent.id"
+ :namespace="agent.kubernetesNamespace"
/>
</div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
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 77d9311743c..92a0b0e550e 100644
--- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -1,9 +1,22 @@
<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { translations } from '~/environments/environment_details/constants';
import ActionsComponent from '~/environments/components/environment_actions.vue';
+import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql';
+
+const EnvironmentApprovalComponent = import(
+ 'ee_component/environments/components/environment_approval.vue'
+);
export default {
components: {
+ GlButton,
ActionsComponent,
+ EnvironmentApproval: () => EnvironmentApprovalComponent,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
props: {
actions: {
@@ -18,14 +31,92 @@ export default {
type: Array,
required: true,
},
+ rollback: {
+ // rollback shape:
+ /*
+ {
+ id: string,
+ name: string,
+ lastDeployment: {
+ commit: Commit,
+ isLast: boolean,
+ },
+ retryUrl: url,
+ };
+ */
+ type: Object,
+ required: false,
+ default: null,
+ },
+ // approvalEnvironment shape:
+ /* {
+ isApprovalActionAvailable: boolean,
+ deploymentIid: string,
+ environment: {
+ name: string,
+ tier: string,
+ requiredApprovalCount: number,
+ },
+ */
+ approvalEnvironment: {
+ type: Object,
+ required: false,
+ default: () => ({
+ isApprovalActionAvailable: false,
+ }),
+ },
},
computed: {
+ isRollbackAvailable() {
+ return Boolean(this.rollback?.lastDeployment);
+ },
+ rollbackIcon() {
+ return this.rollback.lastDeployment.isLast ? 'repeat' : 'redo';
+ },
isActionsShown() {
return this.actions.length > 0;
},
+ deploymentIid() {
+ return this.approvalEnvironment.deploymentIid;
+ },
+ environment() {
+ return this.approvalEnvironment.environment;
+ },
+ rollbackButtonTitle() {
+ return this.rollback.lastDeployment?.isLast
+ ? translations.redeployButtonTitle
+ : translations.rollbackButtonTitle;
+ },
+ },
+ methods: {
+ onRollbackClick() {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToRollback,
+ variables: {
+ environment: this.rollback,
+ },
+ });
+ },
},
};
</script>
<template>
- <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <div>
+ <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <gl-button
+ v-if="isRollbackAvailable"
+ v-gl-modal.confirm-rollback-modal
+ v-gl-tooltip
+ data-testid="rollback-button"
+ :title="rollbackButtonTitle"
+ :icon="rollbackIcon"
+ @click="onRollbackClick"
+ />
+ <environment-approval
+ v-if="approvalEnvironment.isApprovalActionAvailable"
+ :environment="environment"
+ :deployment-iid="deploymentIid"
+ :show-text="false"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js
index 3b33d6a676e..07579092e23 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -30,7 +30,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'job',
label: __('Job'),
- columnClass: 'gl-w-20p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -48,7 +48,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'actions',
label: __('Actions'),
- columnClass: 'gl-w-10p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
},
];
@@ -61,6 +61,8 @@ export const translations = {
),
nextPageButtonLabel: __('Next'),
previousPageButtonLabel: __('Prev'),
+ redeployButtonTitle: s__('Environments|Re-deploy to environment'),
+ rollbackButtonTitle: s__('Environments|Rollback environment'),
};
export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] };
diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue
index 10f8c06e581..128b1aae4d8 100644
--- a/app/assets/javascripts/environments/environment_details/deployments_table.vue
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -54,7 +54,11 @@ export default {
<time-ago-tooltip :time="item.deployed" />
</template>
<template #cell(actions)="{ item }">
- <deployment-actions :actions="item.actions" />
+ <deployment-actions
+ :actions="item.actions"
+ :rollback="item.rollback"
+ :approval-environment="item.deploymentApproval"
+ />
</template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index f4657c5100a..f91e68e793f 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,7 +1,9 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { logError } from '~/lib/logger';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql';
+import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper';
import EmptyState from './empty_state.vue';
import DeploymentsTable from './deployments_table.vue';
@@ -10,6 +12,7 @@ import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants';
export default {
components: {
+ ConfirmRollbackModal,
Pagination,
DeploymentsTable,
EmptyState,
@@ -49,10 +52,14 @@ export default {
};
},
},
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
+ },
},
data() {
return {
project: {},
+ environmentToRollback: {},
isInitialPageDataReceived: false,
isPrefetchingPages: false,
};
@@ -143,5 +150,6 @@ export default {
<pagination :page-info="pageInfo" :disabled="isPaginationDisabled" />
</div>
<empty-state v-if="!isDeploymentTableShown && !isLoading" />
+ <confirm-rollback-modal :environment="environmentToRollback" graphql />
</div>
</template>
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 26514b59995..0482741979b 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -5,6 +5,7 @@ import pageInfoQuery from './queries/page_info.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
+import k8sPodsQuery from './queries/k8s_pods.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
@@ -82,6 +83,14 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: k8sPodsQuery,
+ data: {
+ status: {
+ phase: '',
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
new file mode 100644
index 00000000000..e799623f9bb
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
@@ -0,0 +1,6 @@
+fragment DeploymentJob on CiJob {
+ name
+ id
+ webPath
+ playable
+}
diff --git a/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
new file mode 100644
index 00000000000..1ff68c56362
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
@@ -0,0 +1,3 @@
+fragment ProtectedEnvironment on Environment {
+ id
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
index 0182b3a7234..65d36242afe 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
@@ -1,3 +1,7 @@
+#import "ee_else_ce/environments/graphql/fragments/environment_protected_data.fragment.graphql"
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/environments/graphql/fragments/deployment_job.fragment.graphql"
+
query getEnvironmentDetails(
$projectFullPath: ID!
$environmentName: String
@@ -11,8 +15,9 @@ query getEnvironmentDetails(
name
fullPath
environment(name: $environmentName) {
- id
+ ...ProtectedEnvironment
name
+ tier
lastDeployment(status: SUCCESS) {
id
job {
@@ -40,19 +45,13 @@ query getEnvironmentDetails(
ref
tag
job {
- name
- id
- webPath
- playable
+ ...DeploymentJob
deploymentPipeline: pipeline {
id
jobs(whenExecuted: ["manual"], retried: false) {
nodes {
- id
- name
- playable
+ ...DeploymentJob
scheduledAt
- webPath
}
}
}
@@ -66,17 +65,11 @@ query getEnvironmentDetails(
authorName
authorEmail
author {
- id
- name
- avatarUrl
- webUrl
+ ...User
}
}
triggerer {
- id
- webUrl
- name
- avatarUrl
+ ...User
}
createdAt
finishedAt
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
new file mode 100644
index 00000000000..818bca24d51
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
@@ -0,0 +1,7 @@
+query getK8sPods($configuration: Object, $namespace: String) {
+ k8sPods(configuration: $configuration, namespace: $namespace) @client {
+ status {
+ phase
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index e21670870b8..39e05825cf0 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,3 +1,4 @@
+import { CoreV1Api, Configuration } from '@gitlab/cluster-client';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import {
@@ -71,6 +72,19 @@ export const resolvers = (endpoint) => ({
isLastDeployment(_, { environment }) {
return environment?.lastDeployment?.isLast;
},
+ k8sPods(_, { configuration, namespace }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const podsApi = namespace
+ ? coreV1Api.listCoreV1NamespacedPod(namespace)
+ : coreV1Api.listCoreV1PodForAllNamespaces();
+
+ return podsApi
+ .then((res) => res?.data?.items || [])
+ .catch((err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+ });
+ },
},
Mutation: {
stopEnvironment(_, { environment }, { client }) {
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index b4d1f7326f6..7c102fd04d8 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -62,6 +62,19 @@ type LocalPageInfo {
previousPage: Int!
}
+type k8sPodStatus {
+ phase: String
+}
+
+type LocalK8sPods {
+ status: k8sPodStatus
+}
+
+input LocalConfiguration {
+ basePath: String
+ baseOptions: JSON
+}
+
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
@@ -71,6 +84,7 @@ extend type Query {
environmentToStop: LocalEnvironment
isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean
isLastDeployment(environment: LocalEnvironmentInput): Boolean
+ k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods]
}
extend type Mutation {
diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
index 9802dcbcf78..92efd46df64 100644
--- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
+++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
@@ -62,6 +62,60 @@ export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName)
);
};
+export const getRollbackActionFromDeploymentNode = (deploymentNode, environment) => {
+ const { job, id } = deploymentNode;
+
+ if (!job) {
+ return null;
+ }
+ const isLastDeployment = id === environment.lastDeployment?.id;
+ const { webPath } = job;
+ return {
+ id,
+ name: environment.name,
+ lastDeployment: {
+ commit: deploymentNode.commit,
+ isLast: isLastDeployment,
+ },
+ retryUrl: `${webPath}/retry`,
+ };
+};
+
+const getDeploymentApprovalFromDeploymentNode = (deploymentNode, environment) => {
+ if (!environment.protectedEnvironments || environment.protectedEnvironments.nodes.length === 0) {
+ return {
+ isApprovalActionAvailable: false,
+ };
+ }
+
+ const protectedEnvironmentInfo = environment.protectedEnvironments.nodes[0];
+
+ const hasApprovalRules = protectedEnvironmentInfo.approvalRules.nodes?.length > 0;
+ const hasRequiredApprovals = protectedEnvironmentInfo.requiredApprovalCount > 0;
+
+ const isApprovalActionAvailable = hasRequiredApprovals || hasApprovalRules;
+ const requiredMultipleApprovalRulesApprovals = protectedEnvironmentInfo.approvalRules.nodes.reduce(
+ (requiredApprovals, rule) => {
+ return requiredApprovals + rule.requiredApprovals;
+ },
+ 0,
+ );
+
+ const requiredApprovalCount = hasRequiredApprovals
+ ? protectedEnvironmentInfo.requiredApprovalCount
+ : requiredMultipleApprovalRulesApprovals;
+
+ return {
+ isApprovalActionAvailable,
+ deploymentIid: deploymentNode.iid,
+ environment: {
+ name: environment.name,
+ tier: environment.tier,
+ requiredApprovalCount,
+ },
+ };
+};
+
/**
* This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table
* @param {Object} deploymentNode
@@ -82,5 +136,7 @@ export const convertToDeploymentTableRow = (deploymentNode, environment) => {
created: deploymentNode.createdAt || '',
deployed: deploymentNode.finishedAt || '',
actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name),
+ rollback: getRollbackActionFromDeploymentNode(deploymentNode, environment),
+ deploymentApproval: getDeploymentApprovalFromDeploymentNode(deploymentNode, environment),
};
};
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index d9a523fd806..3f746bc5383 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { parseBoolean } from '../lib/utils/common_utils';
import { apolloProvider } from './graphql/client';
import EnvironmentsApp from './components/environments_app.vue';
@@ -16,6 +17,7 @@ export default (el) => {
projectPath,
defaultBranchName,
projectId,
+ kasTunnelUrl,
} = el.dataset;
return new Vue({
@@ -28,6 +30,7 @@ export default (el) => {
newEnvironmentPath,
helpPagePath,
projectId,
+ kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl),
canCreateEnvironment: parseBoolean(canCreateEnvironment),
},
render(h) {
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index afce2b7f237..5e812c85c96 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -92,7 +92,9 @@ export const initPage = async () => {
el,
apolloProvider: apolloProvider(),
router,
- provide: {},
+ provide: {
+ projectPath: dataSet.projectFullPath,
+ },
render(createElement) {
return createElement('router-view');
},
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
index aeed5450022..afb91d3db51 100644
--- a/app/assets/javascripts/error_tracking/utils.js
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -1,13 +1,13 @@
-/* eslint-disable @gitlab/require-i18n-strings */
+const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings
/**
* Tracks snowplow event when User clicks on error link to Sentry
* @param {String} externalUrl that will be send as a property for the event
*/
export const trackClickErrorLinkToSentryOptions = (url) => ({
- category: 'Error Tracking',
+ category,
action: 'click_error_link_to_sentry',
- label: 'Error Link',
+ label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings
property: url,
});
@@ -15,7 +15,7 @@ export const trackClickErrorLinkToSentryOptions = (url) => ({
* Tracks snowplow event when user views error list
*/
export const trackErrorListViewsOptions = {
- category: 'Error Tracking',
+ category,
action: 'view_errors_list',
};
@@ -23,7 +23,7 @@ export const trackErrorListViewsOptions = {
* Tracks snowplow event when user views error details
*/
export const trackErrorDetailsViewsOptions = {
- category: 'Error Tracking',
+ category,
action: 'view_error_details',
};
@@ -31,6 +31,6 @@ export const trackErrorDetailsViewsOptions = {
* Tracks snowplow event when error status is updated
*/
export const trackErrorStatusUpdateOptions = (status) => ({
- category: 'Error Tracking',
+ category,
action: `update_${status}_status`,
});
diff --git a/app/assets/javascripts/featurable/constants.js b/app/assets/javascripts/featurable/constants.js
new file mode 100644
index 00000000000..23f1c5e415d
--- /dev/null
+++ b/app/assets/javascripts/featurable/constants.js
@@ -0,0 +1,6 @@
+// Matches `app/models/concerns/featurable.rb`
+
+export const FEATURABLE_DISABLED = 'disabled';
+export const FEATURABLE_PRIVATE = 'private';
+export const FEATURABLE_ENABLED = 'enabled';
+export const FEATURABLE_PUBLIC = 'public';
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 93510870915..34e0b94af3b 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -187,7 +187,7 @@ export default {
data-testid="feature-flags-tab-title"
class="page-title gl-font-size-h-display gl-my-0"
>
- {{ s__('FeatureFlags|Feature Flags') }}
+ {{ s__('FeatureFlags|Feature flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 286b214b511..dee1d239c9f 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -108,7 +108,7 @@ export default {
{{ s__('FeatureFlags|Status') }}
</div>
<div class="table-section section-20" role="columnheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-section section-40" role="columnheader">
{{ s__('FeatureFlags|Environment Specs') }}
@@ -148,7 +148,7 @@ export default {
<div class="table-section section-20" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-mobile-content d-flex flex-column js-feature-flag-title">
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 1d7a79f926a..6c8a2d90209 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -138,7 +138,7 @@ export default {
<template #description>
{{ $options.i18n.strategyTypeDescription }}
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</template>
<gl-form-select
@@ -202,7 +202,7 @@ export default {
{{ $options.i18n.environmentsSelectDescription }}
</span>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</div>
</div>
diff --git a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
index 148d9a35b81..c2c46e4265a 100644
--- a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
const InputSetter = {
init(hook) {
this.hook = hook;
@@ -33,11 +31,15 @@ const InputSetter = {
setInput(config, selectedItem) {
const input = config.input || this.hook.trigger;
const newValue = selectedItem.getAttribute(config.valueAttribute);
- const inputAttribute = config.inputAttribute;
-
- if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue);
- if (input.tagName === 'INPUT') return (input.value = newValue);
- return (input.textContent = newValue);
+ const { inputAttribute } = config;
+
+ if (input.hasAttribute(inputAttribute)) {
+ input.setAttribute(inputAttribute, newValue);
+ } else if (input.tagName === 'INPUT') {
+ input.value = newValue;
+ } else {
+ input.textContent = newValue;
+ }
},
destroy() {
diff --git a/app/assets/javascripts/filtered_search/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js
index d7f49bf19d8..3d3470a16d0 100644
--- a/app/assets/javascripts/filtered_search/droplab/utils.js
+++ b/app/assets/javascripts/filtered_search/droplab/utils.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
import { template as _template } from 'lodash';
import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants';
@@ -26,7 +24,7 @@ const utils = {
closest(thisTag, stopTag) {
while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') {
- thisTag = thisTag.parentNode;
+ thisTag = thisTag.parentNode; // eslint-disable-line no-param-reassign
}
return thisTag;
},
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index d865354881a..684375177bb 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -2,7 +2,13 @@ import { last } from 'lodash';
import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { createAlert } from '~/alert';
-import { WORKSPACE_PROJECT } from '~/issues/constants';
+import {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_MERGED,
+ STATUS_OPEN,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -43,7 +49,7 @@ export default class FilteredSearchManager {
this.isGroupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.useDefaultState = useDefaultState;
- this.states = ['opened', 'closed', 'merged', 'all'];
+ this.states = [STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED, STATUS_ALL];
this.page = page;
this.container = FilteredSearchContainer.container;
@@ -743,7 +749,7 @@ export default class FilteredSearchManager {
const { tokens, searchToken } = this.getSearchTokens();
let currentState = state || getParameterByName('state');
if (!currentState && this.useDefaultState) {
- currentState = 'opened';
+ currentState = STATUS_OPEN;
}
if (this.states.includes(currentState)) {
paths.push(`state=${currentState}`);
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 81da8409873..b778e05c7b1 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -343,7 +343,9 @@ class GfmAutoComplete {
icon,
availabilityStatus:
availability && isUserBusy(availability)
- ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
+ ? `<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2"> ${s__(
+ 'UserProfile|Busy',
+ )}</span>`
: '',
});
}
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 60f1b7f5aa4..09ee7de3b6e 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -54,6 +54,7 @@ import { __ } from '~/locale';
const errorMessageClass = 'gl-field-error';
const inputErrorClass = 'gl-field-error-outline';
+const validInputHintClass = '.gl-field-hint-valid';
const errorAnchorSelector = '.gl-field-error-anchor';
const ignoreInputSelector = '.gl-field-error-ignore';
@@ -151,6 +152,7 @@ export default class GlFieldError {
renderInvalid() {
this.inputElement.addClass(inputErrorClass);
this.scopedSiblings.addClass('hidden');
+ this.inputElement.parents('.form-group').find(validInputHintClass).addClass('hidden');
return this.fieldErrorElement.removeClass('hidden');
}
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 77fca45c949..65aa38cfb99 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -22,6 +22,7 @@ export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package';
export const TYPENAME_PROJECT = 'Project';
export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPENAME_SITE_PROFILE = 'DastSiteProfile';
+export const TYPENAME_TODO = 'Todo';
export const TYPENAME_USER = 'User';
export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner';
export const TYPENAME_VULNERABILITY = 'Vulnerability';
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 316bc746051..740eb722629 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -8,6 +8,7 @@ import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.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 = {
typeDefs,
@@ -81,6 +82,14 @@ export const config = {
});
},
},
+ userPermissions: {
+ read(permission = {}) {
+ return {
+ ...permission,
+ setWorkItemMetadata: false,
+ };
+ },
+ },
},
},
MemberInterfaceConnection: {
@@ -126,6 +135,33 @@ export const config = {
};
},
},
+ Group: {
+ fields: {
+ projects: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ descendantGroups: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ },
+ },
+ ProjectConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ GroupConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ Board: {
+ fields: {
+ epics: {
+ keyArgs: ['boardId'],
+ },
+ },
+ },
BoardEpicConnection: {
merge(existing = { nodes: [] }, incoming, { args }) {
if (!args.after) {
@@ -174,6 +210,13 @@ export const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ setActiveBoardItem(_, { boardItem }, { cache }) {
+ cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: { activeBoardItem: boardItem },
+ });
+ return boardItem;
+ },
},
};
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 22629dfb7d8..f7d1efc4d1f 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -21,7 +21,8 @@
"Epic",
"EpicIssue",
"Issue",
- "MergeRequest"
+ "MergeRequest",
+ "WorkItemWidgetCurrentUserTodos"
],
"DependencyLinkMetadata": [
"NugetDependencyLinkMetadata"
@@ -145,6 +146,8 @@
],
"WorkItemWidget": [
"WorkItemWidgetAssignees",
+ "WorkItemWidgetAwardEmoji",
+ "WorkItemWidgetCurrentUserTodos",
"WorkItemWidgetDescription",
"WorkItemWidgetHealthStatus",
"WorkItemWidgetHierarchy",
diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
index d8760f147e1..28405d9dc9b 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
+++ b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
@@ -10,5 +10,9 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) {
}
}
}
+ ... on Issue {
+ id
+ dueDate
+ }
}
}
diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
index 535758750f9..cba13c11c5d 100644
--- a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No archived projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
index 7223321bf3e..7c691b56a43 100644
--- a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No shared projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
index 955cb1ca63e..0bd95d59022 100644
--- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
@@ -43,6 +43,7 @@ export default {
'newProjectPath',
'newSubgroupIllustration',
'newProjectIllustration',
+ 'emptyProjectsIllustration',
'emptySubgroupIllustration',
'canCreateSubgroups',
'canCreateProjects',
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d9781ef9c84..8d202194de7 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+import {
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import { ITEM_TYPE } from '../constants';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index a4c163b0a81..5674e28f5da 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -2,12 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import {
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../constants';
+import { ITEM_TYPE } from '../constants';
import ItemStatsValue from './item_stats_value.vue';
export default {
@@ -24,15 +19,6 @@ export default {
},
},
computed: {
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.item.visibility];
- },
- visibilityTooltip() {
- if (this.item.type === ITEM_TYPE.GROUP) {
- return GROUP_VISIBILITY_TYPE[this.item.visibility];
- }
- return PROJECT_VISIBILITY_TYPE[this.item.visibility];
- },
isProject() {
return this.item.type === ITEM_TYPE.PROJECT;
},
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6f5b03788a8..a5854632040 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,9 +1,4 @@
import { __, s__ } from '~/locale';
-import {
- VISIBILITY_LEVEL_PRIVATE_STRING,
- VISIBILITY_LEVEL_INTERNAL_STRING,
- VISIBILITY_LEVEL_PUBLIC_STRING,
-} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -30,36 +25,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The group and any public projects can be viewed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - The group and its projects can only be viewed by members.',
- ),
-};
-
-export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The project can be accessed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The project can be accessed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
- ),
-};
-
-export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
-};
-
export const OVERVIEW_TABS_SORTING_ITEMS = [
{
label: __('Name'),
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index c3bf3f28509..f6711bde7d0 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -51,6 +51,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -63,6 +64,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
diff --git a/app/assets/javascripts/groups/init_group_readme.js b/app/assets/javascripts/groups/init_group_readme.js
new file mode 100644
index 00000000000..7cde64fed4d
--- /dev/null
+++ b/app/assets/javascripts/groups/init_group_readme.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import apolloProvider from '~/repository/graphql';
+import FilePreview from '~/repository/components/preview/index.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupReadme = () => {
+ const el = document.getElementById('js-group-readme');
+
+ if (!el) return false;
+
+ const { webPath, name } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(FilePreview, {
+ props: {
+ blob: { webPath, name },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index 664d07ca13d..4064520d1ca 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -44,6 +44,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -62,6 +63,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
diff --git a/app/assets/javascripts/groups/settings/components/group_settings_readme.vue b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
new file mode 100644
index 00000000000..123c7fc58f5
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
@@ -0,0 +1,147 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { createProject } from '~/rest_api';
+import { createAlert } from '~/alert';
+import { openWebIDE } from '~/lib/utils/web_ide_navigator';
+import { README_MODAL_ID, GITLAB_README_PROJECT, README_FILE } from '../constants';
+
+export default {
+ name: 'GroupSettingsReadme',
+ i18n: {
+ readme: __('README'),
+ addReadme: __('Add README'),
+ cancel: __('Cancel'),
+ createProjectAndReadme: s__('Groups|Create and add README'),
+ creatingReadme: s__('Groups|Creating README'),
+ existingProjectNewReadme: s__('Groups|This will create a README.md for project %{path}.'),
+ newProjectAndReadme: s__('Groups|This will create a project %{path} and add a README.md.'),
+ errorCreatingProject: s__('Groups|There was an error creating the Group README.'),
+ },
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ groupReadmePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ readmeProjectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ groupPath: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ creatingReadme: false,
+ };
+ },
+ computed: {
+ hasReadme() {
+ return this.groupReadmePath.length > 0;
+ },
+ hasReadmeProject() {
+ return this.readmeProjectPath.length > 0;
+ },
+ pathToReadmeProject() {
+ return this.hasReadmeProject
+ ? this.readmeProjectPath
+ : `${this.groupPath}/${GITLAB_README_PROJECT}`;
+ },
+ modalBody() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.existingProjectNewReadme
+ : this.$options.i18n.newProjectAndReadme;
+ },
+ modalSubmitButtonText() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.addReadme
+ : this.$options.i18n.createProjectAndReadme;
+ },
+ },
+ methods: {
+ hideModal() {
+ this.$refs.modal.hide();
+ },
+ createReadme() {
+ if (this.hasReadmeProject) {
+ openWebIDE(this.readmeProjectPath, README_FILE);
+ } else {
+ this.createProjectWithReadme();
+ }
+ },
+ createProjectWithReadme() {
+ this.creatingReadme = true;
+
+ const projectData = {
+ name: GITLAB_README_PROJECT,
+ namespace_id: this.groupId,
+ };
+
+ createProject(projectData)
+ .then(({ path_with_namespace: pathWithNamespace }) => {
+ openWebIDE(pathWithNamespace, README_FILE);
+ })
+ .catch(() => {
+ this.hideModal();
+ this.creatingReadme = false;
+ createAlert({ message: this.$options.i18n.errorCreatingProject });
+ });
+ },
+ },
+ README_MODAL_ID,
+};
+</script>
+
+<template>
+ <div>
+ <gl-button v-if="hasReadme" icon="doc-text" :href="groupReadmePath">{{
+ $options.i18n.readme
+ }}</gl-button>
+ <gl-button
+ v-else
+ v-gl-modal="$options.README_MODAL_ID"
+ variant="dashed"
+ icon="file-addition"
+ data-testid="group-settings-add-readme-button"
+ >{{ $options.i18n.addReadme }}</gl-button
+ >
+ <gl-modal ref="modal" :modal-id="$options.README_MODAL_ID" :title="$options.i18n.addReadme">
+ <div data-testid="group-settings-modal-readme-body">
+ <gl-sprintf :message="modalBody">
+ <template #path>
+ <code>{{ pathToReadmeProject }}</code>
+ </template>
+ </gl-sprintf>
+ </div>
+ <template #modal-footer>
+ <gl-button variant="default" @click="hideModal">{{ $options.i18n.cancel }}</gl-button>
+ <gl-button v-if="creatingReadme" variant="default" loading disabled>{{
+ $options.i18n.creatingReadme
+ }}</gl-button>
+ <gl-button
+ v-else
+ variant="confirm"
+ data-testid="group-settings-modal-create-readme-button"
+ @click="createReadme"
+ >{{ modalSubmitButtonText }}</gl-button
+ >
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
index c91c2a20529..023ddf29b36 100644
--- a/app/assets/javascripts/groups/settings/constants.js
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -1,3 +1,7 @@
export const LEVEL_TYPES = {
GROUP: 'group',
};
+
+export const README_MODAL_ID = 'add_group_readme_modal';
+export const GITLAB_README_PROJECT = 'gitlab-profile';
+export const README_FILE = 'README.md';
diff --git a/app/assets/javascripts/groups/settings/init_group_settings_readme.js b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
new file mode 100644
index 00000000000..d126228d854
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import GroupSettingsReadme from './components/group_settings_readme.vue';
+
+export const initGroupSettingsReadme = () => {
+ const el = document.getElementById('js-group-settings-readme');
+
+ if (!el) return false;
+
+ const { groupReadmePath, readmeProjectPath, groupPath, groupId } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(GroupSettingsReadme, {
+ props: {
+ groupReadmePath,
+ readmeProjectPath,
+ groupPath,
+ groupId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 9cb96283689..25a84d17379 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -37,7 +37,7 @@ export function initStatusTriggers() {
const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl);
Tracking.event(undefined, 'click_button', {
label: 'user_edit_status',
- property: buttonWithinTopNav ? 'navigation_top' : undefined,
+ property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu',
});
import(
@@ -135,6 +135,8 @@ function initNewNavToggle() {
});
}
-requestIdleCallback(initStatusTriggers);
+if (!gon?.use_new_navigation) {
+ requestIdleCallback(initStatusTriggers);
+}
requestIdleCallback(initNavUserDropdownTracking);
requestIdleCallback(initNewNavToggle);
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index c0a06706fc6..aa349186014 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,7 +1,6 @@
<script>
import {
GlSearchBoxByType,
- GlOutsideDirective as Outside,
GlIcon,
GlToken,
GlTooltipDirective,
@@ -36,6 +35,7 @@ import {
IS_SEARCHING,
IS_FOCUSED,
IS_NOT_FOCUSED,
+ DROPDOWN_CLOSE_TIMEOUT,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -53,7 +53,7 @@ export default {
SEARCH_RESULTS_SCOPE,
KBD_HELP,
},
- directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
@@ -65,7 +65,6 @@ export default {
},
data() {
return {
- showDropdown: false,
isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
@@ -91,7 +90,7 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
- if (!this.showDropdown || !this.isLoggedIn) {
+ if (!this.isFocused || !this.isLoggedIn) {
return false;
}
return this.searchOptions?.length > 0;
@@ -108,7 +107,6 @@ export default {
}
return FIRST_DROPDOWN_INDEX;
},
-
searchInputDescribeBy() {
if (this.isLoggedIn) {
return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
@@ -160,29 +158,18 @@ export default {
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
- this.showDropdown = true;
-
- // check isFocused state to avoid firing duplicate events
- if (!this.isFocused) {
- this.isFocused = true;
- this.$emit('expandSearchBar', true);
+ this.isFocused = true;
+ this.$emit('expandSearchBar');
- Tracking.event(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }
- },
- closeDropdown() {
- this.showDropdown = false;
+ Tracking.event(undefined, 'focus_input', {
+ label: 'global_search',
+ property: 'navigation_top',
+ });
},
collapseAndCloseSearchBar() {
- // we need a delay on this method
- // for the search bar not to remove
- // the clear button from dom
- // and register clicks on dropdown items
+ // without timeout dropdown closes
+ // before click event is dispatched
setTimeout(() => {
- this.showDropdown = false;
this.isFocused = false;
this.$emit('collapseSearchBar');
@@ -190,7 +177,7 @@ export default {
label: 'global_search',
property: 'navigation_top',
});
- }, 200);
+ }, DROPDOWN_CLOSE_TIMEOUT);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
@@ -226,7 +213,6 @@ export default {
<template>
<form
- v-outside="closeDropdown"
role="search"
:aria-label="$options.i18n.SEARCH_GITLAB"
class="header-search gl-relative gl-rounded-base gl-w-full"
@@ -244,12 +230,11 @@ export default {
:placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
- @focus="openDropdown"
- @click="openDropdown"
- @blur="collapseAndCloseSearchBar"
+ @focusin="openDropdown"
+ @focusout="collapseAndCloseSearchBar"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
- @keydown.esc.stop.prevent="closeDropdown"
+ @keydown.esc.stop.prevent="collapseAndCloseSearchBar"
/>
<gl-token
v-if="showScopeHelp"
@@ -301,7 +286,7 @@ export default {
:max="searchOptions.length - 1"
:min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
- @tab="closeDropdown"
+ :enable-cycle="true"
/>
<header-search-default-items
v-if="showDefaultItems"
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index b9bb4e573fd..47aeb2f9caa 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -31,3 +31,5 @@ export const IS_NOT_FOCUSED = 'is-not-focused';
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
+
+export const DROPDOWN_CLOSE_TIMEOUT = 200;
diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js
index 4e9404007ec..64502d13ee2 100644
--- a/app/assets/javascripts/header_search/init.js
+++ b/app/assets/javascripts/header_search/init.js
@@ -2,29 +2,18 @@ import * as Sentry from '@sentry/browser';
import { HEADER_INIT_EVENTS } from './constants';
async function eventHandler(callback = () => {}) {
- if (this.newHeaderSearchFeatureFlag) {
- const { initHeaderSearchApp } = await import(
- /* webpackChunkName: 'globalSearch' */ '~/header_search'
- ).catch((error) => Sentry.captureException(error));
-
- // In case the user started searching before we bootstrapped,
- // let's pass the search along.
- const initialSearchValue = this.searchInputBox.value;
- initHeaderSearchApp(initialSearchValue);
-
- // this is new #search input element. We need to re-find it.
- // And re-focus in it.
- document.querySelector('#search').focus();
- callback();
- return;
- }
-
- const { default: initSearchAutocomplete } = await import(
- /* webpackChunkName: 'globalSearch' */ '../search_autocomplete'
+ const { initHeaderSearchApp } = await import(
+ /* webpackChunkName: 'globalSearch' */ '~/header_search'
).catch((error) => Sentry.captureException(error));
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
+ // In case the user started searching before we bootstrapped,
+ // let's pass the search along.
+ const initialSearchValue = this.searchInputBox.value;
+ initHeaderSearchApp(initialSearchValue);
+
+ // this is new #search input element. We need to re-find it.
+ // And re-focus in it.
+ document.querySelector('#search').focus();
callback();
}
@@ -40,10 +29,7 @@ function initHeaderSearch() {
HEADER_INIT_EVENTS.forEach((eventType) => {
searchInputBox?.addEventListener(
eventType,
- eventHandler.bind(
- { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch },
- cleanEventListeners,
- ),
+ eventHandler.bind({ searchInputBox }, cleanEventListeners),
{ once: true },
);
});
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 92dacf8c94a..d788104edc8 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -43,6 +43,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="edit_mode_tab"
+ data-testid="edit-mode-button"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)"
@@ -60,6 +61,7 @@ export default {
:aria-label="s__('IDE|Review')"
data-container="body"
data-placement="right"
+ data-testid="review-mode-button"
type="button"
class="ide-sidebar-link js-ide-review-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
@@ -78,6 +80,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="commit_mode_tab"
+ data-testid="commit-mode-button"
type="button"
class="ide-sidebar-link js-ide-commit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 2799ea1378e..d05aa960f01 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -82,7 +82,7 @@ export default {
{{ __('Commit Message') }}
<div id="ide-commit-message-popover-container">
<span id="ide-commit-message-question" class="form-text text-muted gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="ide-commit-message-question"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index ea1dbee4669..9f83de840b9 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -69,7 +69,7 @@ export default {
>
<gl-icon name="ellipsis_v" />
</button>
- <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right">
+ <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu">
<template v-if="type === 'tree'">
<li>
<item-button
diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
index 7fca7429ad7..428cf7f55ac 100644
--- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue
+++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
@@ -82,7 +82,7 @@ export default {
<div>{{ __('Commit Message') }}</div>
<div id="commit-message-popover-container">
<span id="commit-message-question" class="gl-gray-700 gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="commit-message-question"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 967c83b320f..29c44d2f596 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -72,7 +72,6 @@ export const initLegacyWebIDE = (el, options = {}) => {
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
- learnGitlabSource: parseBoolean(el.dataset.learnGitlabSource),
});
},
beforeDestroy() {
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index 4d3cefcb107..51af73decad 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -67,6 +67,7 @@ export const initGitlabWebIDE = async (el) => {
links: {
feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE,
userPreferences: el.dataset.userPreferencesPath,
+ signIn: el.dataset.signInPath,
},
editorFont: {
srcUrl: editorFontSrcUrl,
diff --git a/app/assets/javascripts/ide/lib/languages/codeowners.js b/app/assets/javascripts/ide/lib/languages/codeowners.js
new file mode 100644
index 00000000000..e2eed713801
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/codeowners.js
@@ -0,0 +1,39 @@
+const conf = {
+ comments: {
+ lineComment: '#',
+ },
+ autoClosingPairs: [{ open: '[', close: ']' }],
+ surroundingPairs: [{ open: '[', close: ']' }],
+};
+
+const language = {
+ tokenizer: {
+ root: [
+ // comment
+ [/^#.*$/, 'comment'],
+
+ // optional approval
+ [/^\^/, 'constant.numeric'],
+
+ // number of approvers
+ [/\[\d+\]$/, 'constant.numeric'],
+
+ // section
+ [/\[(?!\d+\])[^\]]+\]/, 'namespace'],
+
+ // pattern
+ [/^\s*(\S+)/, 'regexp'],
+
+ // owner
+ [/\S*@.*$/, 'variable.value'],
+ ],
+ },
+};
+
+export default {
+ id: 'codeowners',
+ extensions: ['codeowners'],
+ aliases: ['CODEOWNERS'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
index f758cb7dd86..c2ab954eb73 100644
--- a/app/assets/javascripts/ide/lib/languages/index.js
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -1,6 +1,7 @@
import hcl from './hcl';
import vue from './vue';
+import codeowners from './codeowners';
-const languages = [vue, hcl];
+const languages = [vue, hcl, codeowners];
export default languages;
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 9f1eae03685..06751b926b5 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -1,4 +1,5 @@
import { createAlert } from '~/alert';
+import { STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants';
import service from '../../services';
@@ -16,7 +17,7 @@ export const getMergeRequestsForBranch = (
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
source_project_id: state.projects[projectId].id,
- state: 'opened',
+ state: STATUS_OPEN,
order_by: 'created_at',
per_page: 1,
})
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 572465f7587..79a8ccf2285 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,7 +1,6 @@
import { createAlert } from '~/alert';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
-import Tracking from '~/tracking';
import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
@@ -163,10 +162,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
);
}
- if (rootState.learnGitlabSource) {
- Tracking.event(undefined, 'commit', { label: 'web_ide_learn_gitlab_source' });
- }
-
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
return dispatch('updateFilesAfterCommit', {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 013a0c3ce8f..356bbf28a48 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -32,5 +32,4 @@ export default () => ({
environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
userPreferencesPath: '',
- learnGitlabSource: false,
});
diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js
new file mode 100644
index 00000000000..b9814b5ca60
--- /dev/null
+++ b/app/assets/javascripts/import/constants.js
@@ -0,0 +1,28 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, s__ } from '~/locale';
+
+const STATISTIC_ITEMS = {
+ diff_note: __('Diff notes'),
+ issue: __('Issues'),
+ issue_attachment: s__('GithubImporter|Issue links'),
+ issue_event: __('Issue events'),
+ label: __('Labels'),
+ lfs_object: __('LFS objects'),
+ merge_request_attachment: s__('GithubImporter|Merge request links'),
+ milestone: __('Milestones'),
+ note: __('Notes'),
+ note_attachment: s__('GithubImporter|Note links'),
+ protected_branch: __('Protected branches'),
+ collaborator: s__('GithubImporter|Collaborators'),
+ pull_request: s__('GithubImporter|Pull requests'),
+ pull_request_merged_by: s__('GithubImporter|PR mergers'),
+ pull_request_review: s__('GithubImporter|PR reviews'),
+ pull_request_review_request: s__('GithubImporter|PR reviews'),
+ release: __('Releases'),
+ release_attachment: s__('GithubImporter|Release links'),
+};
+
+// support both camel case and snake case versions
+Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+
+export { STATISTIC_ITEMS };
diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue
new file mode 100644
index 00000000000..86820663025
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_app.vue
@@ -0,0 +1,25 @@
+<script>
+import { s__ } from '~/locale';
+import ImportDetailsTable from './import_details_table.vue';
+
+export default {
+ components: { ImportDetailsTable },
+ props: {
+ project: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ i18n: {
+ pageTitle: s__('Import|GitHub import details'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1>{{ $options.i18n.pageTitle }}</h1>
+ <import-details-table />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue
new file mode 100644
index 00000000000..9ce58e8a9bc
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_table.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlEmptyState, GlIcon, GlLink, GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import { STATISTIC_ITEMS } from '../../constants';
+
+const DEFAULT_PAGE_SIZE = 20;
+
+export default {
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlTable,
+ PaginationBar,
+ },
+ STATISTIC_ITEMS,
+ LOCAL_STORAGE_KEY: 'gl-import-details-page-size',
+ fields: [
+ {
+ key: 'type',
+ label: __('Type'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'title',
+ label: __('Title'),
+ tdClass: 'gl-md-w-30 gl-word-break-word',
+ },
+ {
+ key: 'url',
+ label: __('URL'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'details',
+ label: __('Details'),
+ },
+ ],
+ data() {
+ return {
+ page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
+ };
+ },
+ computed: {
+ items() {
+ return [];
+ },
+
+ hasItems() {
+ return this.items.length > 0;
+ },
+
+ pageInfo() {
+ const mockPageInfo = {
+ page: this.page,
+ perPage: this.perPage,
+ totalPages: this.page,
+ total: this.items.length,
+ };
+ return mockPageInfo;
+ },
+ },
+
+ methods: {
+ setPage(page) {
+ this.page = page;
+ },
+
+ setPageSize(size) {
+ this.perPage = size;
+ this.page = 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" show-empty>
+ <template #empty>
+ <gl-empty-state :title="s__('Import|No import details')" />
+ </template>
+
+ <template #cell(type)="{ item: { type } }">
+ {{ $options.STATISTIC_ITEMS[type] }}
+ </template>
+ <template #cell(url)="{ item: { url } }">
+ <gl-link v-if="url" :href="url" target="_blank">
+ {{ url }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-table>
+ <pagination-bar
+ v-if="hasItems"
+ :page-info="pageInfo"
+ class="gl-mt-5"
+ :storage-key="$options.LOCAL_STORAGE_KEY"
+ @set-page="setPage"
+ @set-page-size="setPageSize"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/index.js b/app/assets/javascripts/import/details/index.js
new file mode 100644
index 00000000000..70850d947e2
--- /dev/null
+++ b/app/assets/javascripts/import/details/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import ImportDetailsApp from './components/import_details_app.vue';
+
+export default () => {
+ const el = document.querySelector('.js-import-details');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'ImportDetailsRoot',
+ render(createElement) {
+ return createElement(ImportDetailsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index ec2ab9d0c3d..96d07803545 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,32 +1,10 @@
<script>
-import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { STATUSES } from '../constants';
-
-const STATISTIC_ITEMS = {
- diff_note: __('Diff notes'),
- issue: __('Issues'),
- issue_attachment: s__('GithubImporter|Issue links'),
- issue_event: __('Issue events'),
- label: __('Labels'),
- lfs_object: __('LFS objects'),
- merge_request_attachment: s__('GithubImporter|Merge request links'),
- milestone: __('Milestones'),
- note: __('Notes'),
- note_attachment: s__('GithubImporter|Note links'),
- protected_branch: __('Protected branches'),
- collaborator: s__('GithubImporter|Collaborators'),
- pull_request: s__('GithubImporter|Pull requests'),
- pull_request_merged_by: s__('GithubImporter|PR mergers'),
- pull_request_review: s__('GithubImporter|PR reviews'),
- pull_request_review_request: s__('GithubImporter|PR reviews'),
- release: __('Releases'),
- release_attachment: s__('GithubImporter|Release links'),
-};
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-// support both camel case and snake case versions
-Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+import { STATISTIC_ITEMS } from '~/import/constants';
+import { STATUSES } from '../constants';
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
@@ -78,6 +56,13 @@ export default {
GlAccordionItem,
GlBadge,
GlIcon,
+ GlLink,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ detailsPath: {
+ default: undefined,
+ },
},
props: {
status: {
@@ -103,13 +88,16 @@ export default {
return this.stats && this.knownStats.length > 0;
},
+ isIncomplete() {
+ return this.status === STATUSES.FINISHED && this.stats && isIncompleteImport(this.stats);
+ },
+
mappedStatus() {
if (this.status === STATUSES.FINISHED) {
- const isIncomplete = this.stats && isIncompleteImport(this.stats);
- return isIncomplete
+ return this.isIncomplete
? {
icon: 'status-alert',
- text: __('Partial import'),
+ text: s__('Import|Partially completed'),
variant: 'warning',
}
: {
@@ -121,6 +109,10 @@ export default {
return STATUS_MAP[this.status];
},
+
+ showDetails() {
+ return Boolean(this.detailsPath) && this.glFeatures.importDetailsPage && this.isIncomplete;
+ },
},
methods: {
@@ -141,25 +133,22 @@ export default {
},
STATISTIC_ITEMS,
+ i18n: {
+ detailsLink: s__('Import|See failures'),
+ },
};
</script>
<template>
<div>
- <div class="gl-display-inline-block gl-w-13">
- <gl-badge
- :icon="mappedStatus.icon"
- :variant="mappedStatus.variant"
- size="md"
- icon-size="sm"
- class="gl-mr-2"
- >
+ <div class="gl-display-inline-block">
+ <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm">
{{ mappedStatus.text }}
</gl-badge>
</div>
<gl-accordion v-if="hasStats" :header-level="3">
<gl-accordion-item :title="__('Details')">
- <ul class="gl-p-0 gl-list-style-none gl-font-sm">
+ <ul class="gl-p-0 gl-mb-3 gl-list-style-none gl-font-sm">
<li v-for="key in knownStats" :key="key">
<div class="gl-display-flex gl-w-20 gl-align-items-center">
<gl-icon
@@ -174,6 +163,7 @@ export default {
</div>
</li>
</ul>
+ <gl-link v-if="showDetails" :href="detailsPath">{{ $options.i18n.detailsLink }}</gl-link>
</gl-accordion-item>
</gl-accordion>
</div>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index aaa37f145aa..55a8bad27b9 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -180,18 +180,20 @@ export default {
class="gl-mb-5"
/>
<div v-if="repositories.length" class="gl-w-full">
- <table>
- <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100">
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ fromHeaderText }}
- </th>
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('To GitLab') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('Status') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th>
+ <table class="table gl-table">
+ <thead>
+ <tr>
+ <th class="gl-w-half">
+ {{ fromHeaderText }}
+ </th>
+ <th class="gl-w-half">
+ {{ __('To GitLab') }}
+ </th>
+ <th>
+ {{ __('Status') }}
+ </th>
+ <th></th>
+ </tr>
</thead>
<tbody>
<template v-for="repo in repositories">
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 265cca9070e..b20309baac7 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -155,16 +155,16 @@ export default {
<template>
<tr
- class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
+ class="gl-h-11"
data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName"
>
- <td class="gl-p-4 gl-vertical-align-top">
+ <td>
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
</gl-link>
- <div v-if="isFinished" class="gl-font-sm">
+ <div v-if="isFinished" class="gl-font-sm gl-mt-2">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link
@@ -179,52 +179,50 @@ export default {
</gl-sprintf>
</div>
</td>
- <td
- class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
- data-testid="fullPath"
- data-qa-selector="project_path_content"
- >
- <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
- <template v-else-if="isImportNotStarted || isSelectedForReimport">
- <div class="gl-display-flex gl-align-items-stretch gl-w-full">
- <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
- <template v-if="namespaces.length">
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="ns in namespaces"
- :key="ns.fullPath"
- data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns.fullPath"
- @click="updateImportTarget({ targetNamespace: ns.fullPath })"
- >
- {{ ns.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
- userNamespace
- }}</gl-dropdown-item>
- </import-group-dropdown>
- <div
- class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
- >
- /
+ <td data-testid="fullPath" data-qa-selector="project_path_content">
+ <div class="gl-display-flex gl-sm-flex-wrap">
+ <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
+ <template v-else-if="isImportNotStarted || isSelectedForReimport">
+ <div class="gl-display-flex gl-align-items-stretch gl-w-full">
+ <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
+ <template v-if="namespaces.length">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in namespaces"
+ :key="ns.fullPath"
+ data-qa-selector="target_group_dropdown_item"
+ :data-qa-group-name="ns.fullPath"
+ @click="updateImportTarget({ targetNamespace: ns.fullPath })"
+ >
+ {{ ns.fullPath }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
+ userNamespace
+ }}</gl-dropdown-item>
+ </import-group-dropdown>
+ <div
+ class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ >
+ /
+ </div>
+ <gl-form-input
+ ref="newNameInput"
+ v-model="newNameInput"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ data-qa-selector="project_path_field"
+ />
</div>
- <gl-form-input
- ref="newNameInput"
- v-model="newNameInput"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
- data-qa-selector="project_path_field"
- />
- </div>
- </template>
- <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </template>
+ <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </div>
</td>
- <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
+ <td data-qa-selector="import_status_indicator">
<import-status :status="importStatus" :stats="stats" />
</td>
- <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap">
+ <td data-testid="actions" class="gl-white-space-nowrap">
<gl-tooltip :target="() => $refs.cancelButton.$el">
<div class="gl-text-left">
<p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p>
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 66ffd378426..f898e23b47a 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -66,12 +66,16 @@ export default function mountImportProjectsTable({
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
+ const { detailsPath } = mountElement.dataset;
return new Vue({
el: mountElement,
name: 'ImportProjectsRoot',
store,
apolloProvider,
+ provide: {
+ detailsPath,
+ },
render(createElement) {
// We are using attrs instead of props so root-level component with inheritAttrs
// will be able to pass them down
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index f8e70fea7aa..e15cb2224f4 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -12,6 +12,7 @@ import {
GlEmptyState,
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
+import { STATUS_CLOSED } from '~/issues/constants';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__, n__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/constants';
@@ -301,6 +302,9 @@ export default {
getEscalationStatus(escalationStatus) {
return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
},
+ isClosed(item) {
+ return item.state === STATUS_CLOSED;
+ },
showIncidentLink({ iid }) {
return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
},
@@ -397,7 +401,7 @@ export default {
<template #cell(title)="{ item }">
<div
:class="{
- 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed',
+ 'gl-display-flex gl-align-items-center gl-max-w-full': isClosed(item),
}"
>
<gl-link
@@ -411,7 +415,7 @@ export default {
</tooltip-on-truncate>
</gl-link>
<gl-icon
- v-if="item.state === 'closed'"
+ v-if="isClosed(item)"
name="issue-close"
class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index dde40ec2983..6f8d5cf5f89 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { s__ } from '~/locale';
export const I18N = {
@@ -51,11 +50,13 @@ export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
+const category = 'Incident Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
/**
* Tracks snowplow event when user clicks create new incident
*/
export const trackIncidentCreateNewOptions = {
- category: 'Incident Management',
+ category,
action: 'create_incident_button_clicks',
};
@@ -63,7 +64,7 @@ export const trackIncidentCreateNewOptions = {
* Tracks snowplow event when user views incidents list
*/
export const trackIncidentListViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incidents_list',
};
@@ -71,6 +72,6 @@ export const trackIncidentListViewsOptions = {
* Tracks snowplow event when user views incident details
*/
export const trackIncidentDetailsViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incident_details',
};
diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
index 8413fe92f89..82350a3987e 100644
--- a/app/assets/javascripts/init_diff_stats_dropdown.js
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -4,7 +4,15 @@ import { stickyMonitor } from './lib/utils/sticky';
export const initDiffStatsDropdown = (stickyTop) => {
if (stickyTop) {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false);
+ // We spend quite a bit of effort in our CSS to set the correct padding-top on the
+ // layout page, so we re-use the padding set there to determine at what height our
+ // element should be sticky
+ const pageLayout = document.querySelector('.layout-page');
+ const pageLayoutTopOffset = pageLayout
+ ? parseFloat(window.getComputedStyle(pageLayout).getPropertyValue('padding-top') || 0)
+ : 0;
+
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), pageLayoutTopOffset, false);
}
const el = document.querySelector('.js-diff-stats-dropdown');
diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
index 767675cc64c..aaa04dc4b43 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_notification.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
@@ -1,12 +1,7 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { GROUP_MODAL_ALERT_BODY } from '../constants';
-
-const SHARE_GROUP_LINK =
- 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group';
export default {
- SHARE_GROUP_LINK,
name: 'InviteGroupNotification',
components: { GlAlert, GlSprintf, GlLink },
inject: ['freeUsersLimit'],
@@ -15,18 +10,23 @@ export default {
type: String,
required: true,
},
- },
- i18n: {
- body: GROUP_MODAL_ALERT_BODY,
+ notificationText: {
+ type: String,
+ required: true,
+ },
+ notificationLink: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
<gl-alert variant="warning" :dismissible="false">
- <gl-sprintf :message="$options.i18n.body">
+ <gl-sprintf :message="notificationText">
<template #link="{ content }">
- <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{
+ <gl-link :href="notificationLink" target="_blank" class="gl-label-link">{{
content
}}</gl-link>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 3be3b9df747..51355baef99 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -190,7 +190,13 @@ export default {
@submit="sendInvite"
>
<template #alert>
- <invite-group-notification v-if="freeUserCapEnabled" :name="name" />
+ <invite-group-notification
+ v-if="freeUserCapEnabled"
+ :name="name"
+ :notification-text="$options.labels[inviteTo].notificationText"
+ :notification-link="$options.labels[inviteTo].notificationLink"
+ class="gl-mb-5"
+ />
</template>
<template #select>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 812e39e6392..e99a61caf3f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -13,20 +13,21 @@ import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import Tracking from '~/tracking';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from 'ee_else_ce/invite_members/utils/member_utils';
+import {
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
- LEARN_GITLAB,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
} from '../constants';
import eventHub from '../event_hub';
import { responseFromSuccess } from '../utils/response_message_parser';
-import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import {
displaySuccessfulInvitationAlert,
@@ -170,11 +171,7 @@ export default {
);
},
tasksToBeDoneEnabled() {
- return (
- (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
- this.isOnLearnGitlab) &&
- this.tasksToBeDoneOptions.length
- );
+ return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length;
},
showTasksToBeDone() {
return (
@@ -193,9 +190,6 @@ export default {
? this.selectedTaskProject.id
: '';
},
- isOnLearnGitlab() {
- return this.source === LEARN_GITLAB;
- },
showUserLimitNotification() {
return !isEmpty(this.usersLimitDataset.alertVariant);
},
@@ -248,14 +242,10 @@ export default {
eventHub.$on('openModal', (options) => {
this.openModal(options);
- if (this.isOnLearnGitlab) {
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source);
- }
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ source: 'in_product_marketing_email' });
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
methods: {
@@ -283,16 +273,29 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- trackEvent(experimentName, eventName) {
- const tracking = new ExperimentTracking(experimentName);
- tracking.event(eventName);
- },
showEmptyInvitesAlert() {
this.invalidFeedbackMessage = this.$options.labels.placeHolder;
this.shouldShowEmptyInvitesAlert = true;
this.$refs.alerts.focus();
},
- sendInvite({ accessLevel, expiresAt }) {
+ getInvitePayload({ accessLevel, expiresAt }) {
+ const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ return {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ ...email,
+ ...userId,
+ };
+ },
+ async sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
@@ -301,40 +304,28 @@ export default {
return;
}
- const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+ this.trackInviteMembersForTask();
const apiAddByInvite = this.isProject
? Api.inviteProjectMembers.bind(Api)
: Api.inviteGroupMembers.bind(Api);
- const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
- const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+ try {
+ const payload = this.getInvitePayload({ accessLevel, expiresAt });
+ const response = await apiAddByInvite(this.id, payload);
- this.trackinviteMembersForTask();
-
- apiAddByInvite(this.id, {
- format: 'json',
- expires_at: expiresAt,
- access_level: accessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- ...email,
- ...userId,
- })
- .then((response) => {
- const { error, message } = responseFromSuccess(response);
+ const { error, message } = responseFromSuccess(response);
- if (error) {
- this.showMemberErrors(message);
- } else {
- this.onInviteSuccess();
- }
- })
- .catch((e) => this.showInvalidFeedbackMessage(e))
- .finally(() => {
- this.isLoading = false;
- });
+ if (error) {
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch (e) {
+ this.showInvalidFeedbackMessage(e);
+ } finally {
+ this.isLoading = false;
+ }
},
showMemberErrors(message) {
this.invalidMembers = message;
@@ -344,11 +335,10 @@ export default {
// initial token creation hits this and nothing is found... so safe navigation
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
- trackinviteMembersForTask() {
+ trackInviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
- const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
- tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
+ this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property });
},
onCancel() {
this.track('click_cancel', { label: this.source });
@@ -377,9 +367,7 @@ export default {
}
},
showSuccessMessage() {
- if (this.isOnLearnGitlab) {
- eventHub.$emit('showSuccessfulInvitationsAlert');
- } else {
+ if (!triggerExternalAlert(this.source)) {
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
@@ -431,7 +419,9 @@ export default {
@access-level="onAccessLevelUpdate"
>
<template #intro-text-before>
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1">
+ <gl-emoji data-name="tada" />
+ </div>
</template>
<template #intro-text-after>
<br />
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 86badd16d6c..d5e9e498c6b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,12 +1,11 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const SEARCH_DELAY = 200;
export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
- name: 'invite_members_for_task',
- view: 'modal_opened_from_email',
submit: 'submit',
};
export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully';
@@ -61,9 +60,18 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__(
"InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
);
-export const GROUP_MODAL_ALERT_BODY = s__(
- 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+export const GROUP_MODAL_TO_GROUP_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
);
+export const GROUP_MODAL_TO_GROUP_ALERT_LINK = helpPagePath('user/group/manage', {
+ anchor: 'share-a-group-with-another-group',
+});
+export const GROUP_MODAL_TO_PROJECT_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your project%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+);
+export const GROUP_MODAL_TO_PROJECT_ALERT_LINK = helpPagePath('user/project/members/index', {
+ anchor: 'add-groups-to-a-project',
+});
export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite');
export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite');
@@ -129,16 +137,19 @@ export const GROUP_MODAL_LABELS = {
title: GROUP_MODAL_DEFAULT_TITLE,
toGroup: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK,
},
toProject: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK,
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
-export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index d85162626f1..240a3a89686 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -1,4 +1,14 @@
+import { getParameterValues } from '~/lib/utils/url_utility';
+
export function memberName(member) {
// user defined tokens(invites by email) will have email in `name` and will not contain `username`
return member.username || member.name;
}
+
+export function triggerExternalAlert() {
+ return false;
+}
+
+export function qualifiesForTasksToBeDone() {
+ return getParameterValues('open_modal')[0] === 'invite_members_for_task';
+}
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index c4b9bdb150b..d32336395dc 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -111,13 +111,14 @@ export default {
<div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
<gl-icon
v-if="hasState"
+ :id="`iconElementXL-${itemId}`"
ref="iconElementXL"
:class="iconClasses"
:name="iconName"
:title="stateTitle"
:aria-label="state"
/>
- <gl-tooltip :target="() => $refs.iconElementXL">
+ <gl-tooltip :target="`iconElementXL-${itemId}`">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
@@ -141,7 +142,7 @@ export default {
<!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). -->
<!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 -->
<div
- class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-wrap-reverse"
+ class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-reverse"
>
<!-- Path area: status icon (<XL), path, issue # -->
<div
@@ -221,7 +222,7 @@ export default {
category="tertiary"
size="small"
:disabled="removeDisabled"
- class="js-issue-item-remove-button"
+ class="js-issue-item-remove-button gl-mr-2"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 9ffcf14c943..799c0a18444 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -125,8 +125,13 @@ export default {
</script>
<template>
- <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant">
- <gl-icon :name="badgeIcon" />
+ <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>
</template>
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 8a094d5d688..a1525ad2bec 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -6,12 +6,13 @@ import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'
import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import ZenMode from '~/zen_mode';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
const DATA_ISSUES_NEW_PATH = 'data-new-issue-path';
-function organizeQuery(obj, isFallbackKey = false) {
+export function organizeQuery(obj, isFallbackKey = false) {
if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
return obj;
}
@@ -83,11 +84,10 @@ export default class IssuableForm {
this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH));
this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
- this.descriptionField = this.form.find('textarea[name*="[description]"]');
+ this.descriptionField = () => this.form.find('textarea[name*="[description]"]');
+ this.submitButton = this.form.find('.js-issuable-submit-button');
this.draftCheck = document.querySelector('input.js-toggle-draft');
- if (!(this.titleField.length && this.descriptionField.length)) {
- return;
- }
+ if (!this.titleField.length) return;
this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
@@ -99,7 +99,7 @@ export default class IssuableForm {
if ($issuableDueDate.length) {
const calendar = new Pikaday({
field: $issuableDueDate.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $issuableDueDate.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
@@ -125,13 +125,6 @@ export default class IssuableForm {
);
IssuableForm.addAutosave(
autosaveMap,
- 'description',
- this.form.find('textarea[name*="[description]"]').get(0),
- this.searchTerm,
- this.fallbackKey,
- );
- IssuableForm.addAutosave(
- autosaveMap,
'confidential',
this.form.find('input:checkbox[name*="[confidential]"]').get(0),
this.searchTerm,
@@ -148,7 +141,21 @@ export default class IssuableForm {
return autosaveMap;
}
- handleSubmit() {
+ async handleSubmit(event) {
+ event.preventDefault();
+
+ const form = event.target;
+ const descriptionText = this.descriptionField().val();
+
+ if (containsSensitiveToken(descriptionText)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.submitButton.removeAttr('disabled');
+ this.submitButton.removeClass('disabled');
+ return false;
+ }
+ }
+ form.submit();
return this.resetAutosave();
}
diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
index 4a6edae0c06..cbad6a2537d 100644
--- a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
@@ -1,4 +1,5 @@
import { isEmpty } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -107,13 +108,13 @@ const mixins = {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
- return this.state === 'opened' || this.state === 'reopened';
+ return this.state === STATUS_OPEN || this.state === STATUS_REOPENED;
},
isClosed() {
- return this.state === 'closed';
+ return this.state === STATUS_CLOSED;
},
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
hasTitle() {
return this.title.length > 0;
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index b7d885ed8a7..d35355a8f26 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -5,6 +5,7 @@ export const STATUS_CLOSED = 'closed';
export const STATUS_MERGED = 'merged';
export const STATUS_OPEN = 'opened';
export const STATUS_REOPENED = 'reopened';
+export const STATUS_LOCKED = 'locked';
export const TITLE_LENGTH_MAX = 255;
@@ -22,4 +23,6 @@ export const IssuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
+ [STATUS_MERGED]: __('Merged'),
+ [STATUS_LOCKED]: __('Open'),
};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index c821c18bcb9..de0334b4ffe 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -432,7 +432,7 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
- if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
+ if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) {
this.droplab.hooks.forEach((hook) => hook.list.toggle());
return;
@@ -442,9 +442,9 @@ export default class CreateMergeRequestDropdown {
return;
}
- if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (event.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.currentTarget.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 83387d3ac29..61531880842 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -3,12 +3,10 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GLForm from '~/gl_form';
import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
-import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { TYPE_INCIDENT } from '~/issues/constants';
import Issue from '~/issues/issue';
-import { initTitleSuggestions, initTypePopover } from '~/issues/new';
+import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import { initRelatedIssues } from '~/related_issues';
import {
@@ -38,15 +36,14 @@ export function initFilteredSearchServiceDesk() {
}
export function initForm() {
- new GLForm($('.issue-form')); // eslint-disable-line no-new
new IssuableForm($('.issue-form')); // eslint-disable-line no-new
IssuableLabelSelector();
- new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new
new LabelsSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initTitleSuggestions();
initTypePopover();
+ initTypeSelect();
mountMilestoneDropdown();
}
diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
index d11540ad3dd..5199c36db5a 100644
--- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
@@ -77,7 +77,7 @@ export default {
class="issuable-milestone gl-mr-3"
data-testid="issuable-milestone"
>
- <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
+ <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate" class="gl-font-sm">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 99064a50e3f..2c6f11b682c 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -76,8 +76,8 @@ export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const i18n = {
calendarLabel: __('Subscribe to calendar'),
- closed: __('CLOSED'),
- closedMoved: __('CLOSED (MOVED)'),
+ closed: __('Closed'),
+ closedMoved: __('Closed (moved)'),
confidentialNo: __('No'),
confidentialYes: __('Yes'),
downvotes: __('Downvotes'),
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index b590006929a..e64870152bd 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -1,7 +1,9 @@
import produce from 'immer';
-import createDefaultClient from '~/lib/graphql';
+import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+let client;
+
const resolvers = {
Mutation: {
reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
@@ -22,6 +24,10 @@ const resolvers = {
},
};
-export const gqlClient = gon.features?.frontendCaching
- ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' })
- : createDefaultClient(resolvers);
+export async function gqlClient() {
+ if (client) return client;
+ client = gon.features?.frontendCaching
+ ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' })
+ : createDefaultClient(resolvers);
+ return client;
+}
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index aca894549e4..a97b59c1e4f 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue';
import { gqlClient } from './graphql';
-export function mountJiraIssuesListApp() {
+export async function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status-root');
if (!el) {
@@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() {
el,
name: 'JiraIssuesImportStatusRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render(createComponent) {
return createComponent(JiraIssuesImportStatusApp, {
@@ -42,7 +42,7 @@ export function mountJiraIssuesListApp() {
});
}
-export function mountIssuesListApp() {
+export async function mountIssuesListApp() {
const el = document.querySelector('.js-issues-list-root');
if (!el) {
@@ -100,7 +100,7 @@ export function mountIssuesListApp() {
el,
name: 'IssuesListRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
router: new VueRouter({
base: window.location.pathname,
diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
index a01f4f747b9..be2237ae2a2 100644
--- a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+import { STATUS_CLOSED } from '~/issues/constants';
import { __ } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -42,7 +43,7 @@ export default {
].filter(({ count }) => count);
},
isClosed() {
- return this.suggestion.state === 'closed';
+ return this.suggestion.state === STATUS_CLOSED;
},
stateIconClass() {
return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
diff --git a/app/assets/javascripts/issues/new/components/type_select.vue b/app/assets/javascripts/issues/new/components/type_select.vue
new file mode 100644
index 00000000000..81c3a769d26
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/type_select.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants';
+import { visitUrl } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ selectType: __('Select type'),
+ issuableType: {
+ [TYPE_ISSUE]: __('Issue'),
+ [TYPE_INCIDENT]: __('Incident'),
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlIcon,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ selectedType: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ isIssueAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ isIncidentAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ issuePath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ incidentPath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ },
+ data() {
+ return {
+ selected: this.selectedType,
+ };
+ },
+ computed: {
+ toggleText() {
+ return this.selectedType
+ ? this.$options.i18n.issuableType[this.selectedType]
+ : this.$options.i18n.selectType;
+ },
+ dropdownItems() {
+ const issueItem = this.isIssueAllowed
+ ? {
+ value: TYPE_ISSUE,
+ text: __('Issue'),
+ icon: 'issue-type-issue',
+ href: this.issuePath,
+ }
+ : null;
+ const incidentItem = this.isIncidentAllowed
+ ? {
+ value: TYPE_INCIDENT,
+ text: __('Incident'),
+ icon: 'issue-type-incident',
+ href: this.incidentPath,
+ tracking: {
+ action: 'select_issue_type_incident',
+ label: 'select_issue_type_incident_dropdown_option',
+ },
+ }
+ : null;
+
+ return [issueItem, incidentItem].filter(Boolean);
+ },
+ },
+ methods: {
+ selectType(type) {
+ const selectedItem = this.dropdownItems.find((item) => item.value === type);
+ if (selectedItem.tracking) {
+ const { action, label } = selectedItem.tracking;
+ this.track(action, { label });
+ }
+
+ visitUrl(selectedItem.href);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-collapsible-listbox
+ v-model="selected"
+ :header-text="$options.i18n.selectType"
+ :toggle-text="toggleText"
+ :items="dropdownItems"
+ block
+ class="js-issuable-type-filter-dropdown-wrap"
+ @select="selectType"
+ >
+ <template #list-item="{ item }">
+ <gl-icon :name="item.icon" :size="16" />
+ {{ item.text }}
+ </template>
+ </gl-collapsible-listbox>
+</template>
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
index 91599502996..84a170e6564 100644
--- a/app/assets/javascripts/issues/new/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -1,8 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import TitleSuggestions from './components/title_suggestions.vue';
import TypePopover from './components/type_popover.vue';
+import TypeSelect from './components/type_select.vue';
export function initTitleSuggestions() {
const el = document.getElementById('js-suggestions');
@@ -56,3 +58,28 @@ export function initTypePopover() {
render: (createElement) => createElement(TypePopover),
});
}
+
+export function initTypeSelect() {
+ const el = document.getElementById('js-type-select');
+
+ if (!el) {
+ return undefined;
+ }
+
+ const { selectedType, isIssueAllowed, isIncidentAllowed, issuePath, incidentPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'TypeSelectRoot',
+ render: (createElement) =>
+ createElement(TypeSelect, {
+ props: {
+ selectedType,
+ isIssueAllowed: parseBoolean(isIssueAllowed),
+ isIncidentAllowed: parseBoolean(isIncidentAllowed),
+ issuePath,
+ incidentPath,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index 15f97222971..bc32a15a420 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -14,6 +14,7 @@ import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
@@ -95,10 +96,10 @@ export default {
required: false,
default: '',
},
- initialTaskStatus: {
- type: String,
+ initialTaskCompletionStatus: {
+ type: Object,
required: false,
- default: '',
+ default: () => ({}),
},
updatedAt: {
type: String,
@@ -197,7 +198,7 @@ export default {
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
- taskStatus: this.initialTaskStatus,
+ taskCompletionStatus: this.initialTaskCompletionStatus,
lock_version: this.lockVersion,
});
@@ -222,9 +223,6 @@ export default {
formState() {
return this.store.formState;
},
- hasUpdated() {
- return Boolean(this.state.updatedAt);
- },
issueChanged() {
const {
store: {
@@ -379,7 +377,7 @@ export default {
this.showForm = false;
},
- updateIssuable() {
+ async updateIssuable() {
this.setFormState({ updateLoading: true });
const {
@@ -392,6 +390,14 @@ export default {
this.alert?.dismiss();
+ if (containsSensitiveToken(issuablePayload.description)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.setFormState({ updateLoading: false });
+ return false;
+ }
+ }
+
return this.service
.updateIssuable(issuablePayload)
.then((res) => res.data)
@@ -557,7 +563,6 @@ export default {
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
- :task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
:lock-version="state.lock_version"
@@ -570,7 +575,7 @@ export default {
/>
<edited-component
- v-if="hasUpdated"
+ :task-completion-status="state.taskCompletionStatus"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index bdee6c5fe9a..3721f224d5e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,6 +1,5 @@
<script>
import { GlToast } from '@gitlab/ui';
-import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
@@ -59,11 +58,6 @@ export default {
required: false,
default: '',
},
- taskStatus: {
- type: String,
- required: false,
- default: '',
- },
issuableType: {
type: String,
required: false,
@@ -138,7 +132,10 @@ export default {
},
watch: {
descriptionHtml(newDescription, oldDescription) {
- if (!this.initialUpdate && newDescription !== oldDescription) {
+ if (
+ !this.initialUpdate &&
+ this.stripClientState(newDescription) !== this.stripClientState(oldDescription)
+ ) {
this.animateChange();
} else {
this.initialUpdate = false;
@@ -148,16 +145,12 @@ export default {
this.renderGFM();
});
},
- taskStatus() {
- this.updateTaskStatusText();
- },
},
mounted() {
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
this.renderGFM();
- this.updateTaskStatusText();
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
@@ -282,24 +275,6 @@ export default {
this.$emit('taskListUpdateFailed');
},
- updateTaskStatusText() {
- const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
- const $issuableHeader = $('.issuable-meta');
- const $tasks = $('#task_status', $issuableHeader);
- const $tasksShort = $('#task_status_short', $issuableHeader);
-
- if (taskRegexMatches) {
- $tasks.text(this.taskStatus);
- $tasksShort.text(
- `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
- taskRegexMatches[2] > 1 ? 's' : ''
- }`,
- );
- } else {
- $tasks.text('');
- $tasksShort.text('');
- }
- },
createTaskListItemActions(provide) {
const app = new Vue({
el: document.createElement('div'),
@@ -349,6 +324,9 @@ export default {
listItem.append(element);
}
},
+ stripClientState(description) {
+ return description.replaceAll('<details open="true">', '<details>');
+ },
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 5138a4530e9..6a0edb59b65 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,13 +1,20 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeAgoTooltip,
+ GlLink,
GlSprintf,
},
props: {
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
updatedAt: {
type: String,
required: false,
@@ -25,36 +32,61 @@ export default {
},
},
computed: {
+ completedCount() {
+ return this.taskCompletionStatus.completed_count;
+ },
+ count() {
+ return this.taskCompletionStatus.count;
+ },
hasUpdatedBy() {
return this.updatedByName && this.updatedByPath;
},
+ showCheck() {
+ return this.completedCount === this.count;
+ },
+ taskStatus() {
+ const { completedCount, count } = this;
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
},
};
</script>
<template>
<small class="edited-text js-issue-widgets">
- <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- </gl-sprintf>
- <gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')">
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
- <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
+ <template v-if="taskStatus">
+ <template v-if="showCheck">&check;</template>
+ {{ taskStatus }}
+ <template v-if="updatedAt">&middot;</template>
+ </template>
+
+ <template v-if="updatedAt">
+ <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ <template #author>
+ <gl-link :href="updatedByPath" class="gl-font-sm gl-hover-text-gray-900 gl-text-gray-700">
+ {{ updatedByName }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
</small>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 243666b2323..8267c0130a3 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -199,7 +199,7 @@ export default {
<p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
</div>
- <gl-form-group v-if="glFeatures.incidentEventTags">
+ <gl-form-group>
<label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags">
{{ $options.i18n.tagsLabel }}
<timeline-events-tags-popover />
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 7c6ff002014..373c5970e64 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
@@ -42,8 +42,8 @@ export default {
},
mounted() {
this.gitlabBasePath = retrieveBaseUrl();
- setApiBaseURL(this.gitlabBasePath);
if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) {
+ setApiBaseURL(this.gitlabBasePath);
this.showSetupInstructions = true;
}
},
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 2018942a7e8..1c7ba1d331b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -45,7 +45,9 @@ export default {
data-testid="artifacts-remove-timeline"
>
<span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
- <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span>
+ <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{
+ s__('Job|The artifacts will be removed')
+ }}</span>
<timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
<gl-link
:href="helpUrl"
@@ -53,11 +55,11 @@ export default {
rel="noopener noreferrer nofollow"
data-testid="artifact-expired-help-link"
>
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</p>
<p v-else-if="isLocked" class="build-detail-row">
- <span data-testid="job-locked-message">{{
+ <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">{{
s__(
'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
)
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 3d87cea6445..ff7982319e7 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -19,7 +19,7 @@ export default {
loadingAriaLabel: __('Loading'),
},
filterSearchBoxStyles:
- 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b',
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100',
components: {
GlAlert,
GlSkeletonLoader,
@@ -140,6 +140,19 @@ export default {
this.infiniteScrollingTriggered = false;
this.filterSearchTriggered = true;
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ updateHistory({
+ url: setUrlParams({ statuses: null }, window.location.href, true),
+ });
+
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+ this.$apollo.queries.jobsCount.refetch({ statuses: null });
+
+ return;
+ }
+
// Eventually there will be more tokens available
// this code is written to scale for those tokens
filters.forEach((filter) => {
@@ -223,7 +236,7 @@ export default {
<jobs-table-empty-state v-else-if="showEmptyState" />
- <jobs-table v-else :jobs="jobs.list" />
+ <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" />
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon
diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue
index 2be404de1e1..904e1ba9a47 100644
--- a/app/assets/javascripts/labels/components/delete_label_modal.vue
+++ b/app/assets/javascripts/labels/components/delete_label_modal.vue
@@ -81,14 +81,9 @@ export default {
</gl-sprintf>
<template #modal-footer>
<gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- category="primary"
- variant="danger"
- :href="destroyPath"
- data-method="delete"
- data-testid="delete-button"
- >{{ __('Delete label') }}</gl-button
- >
+ <gl-button category="primary" variant="danger" :href="destroyPath" data-method="delete">{{
+ __('Delete label')
+ }}</gl-button>
</template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js
index 60ab0c92256..fa0104fcf12 100644
--- a/app/assets/javascripts/labels/create_label_dropdown.js
+++ b/app/assets/javascripts/labels/create_label_dropdown.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import Api from '~/api';
import { humanize } from '~/lib/utils/text_utility';
@@ -49,6 +47,7 @@ export default class CreateLabelDropdown {
addBinding() {
const self = this;
+ // eslint-disable-next-line func-names
this.$colorSuggestions.on('click', function (e) {
const $this = $(this);
self.addColorValue(e, $this);
diff --git a/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
new file mode 100644
index 00000000000..5d2a002bf85
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
@@ -0,0 +1,97 @@
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable class-methods-use-this */
+import { db } from './local_db';
+
+/**
+ * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1]
+ *
+ * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15
+ */
+export class IndexedDBPersistentStorage {
+ static async create() {
+ await db.open();
+
+ return new IndexedDBPersistentStorage();
+ }
+
+ async getItem(queryId) {
+ const resultObj = {};
+ const selectedQuery = await db.table('queries').get(queryId);
+ const tableNames = new Set(db.tables.map((table) => table.name));
+
+ if (selectedQuery) {
+ resultObj.ROOT_QUERY = selectedQuery;
+
+ const lookupTable = [];
+
+ const parseObjectsForRef = async (selObject) => {
+ const ops = Object.values(selObject).map(async (child) => {
+ if (!child) {
+ return;
+ }
+
+ if (child.__ref) {
+ const pathId = child.__ref;
+ const [refType, ...refKeyParts] = pathId.split(':');
+ const refKey = refKeyParts.join(':');
+
+ if (
+ !resultObj[pathId] &&
+ !lookupTable.includes(pathId) &&
+ tableNames.has(refType.toLowerCase())
+ ) {
+ lookupTable.push(pathId);
+ const selectedEntity = await db.table(refType.toLowerCase()).get(refKey);
+ if (selectedEntity) {
+ await parseObjectsForRef(selectedEntity);
+ resultObj[pathId] = selectedEntity;
+ }
+ }
+ } else if (typeof child === 'object') {
+ await parseObjectsForRef(child);
+ }
+ });
+
+ return Promise.all(ops);
+ };
+
+ await parseObjectsForRef(resultObj.ROOT_QUERY);
+ }
+
+ return resultObj;
+ }
+
+ async setItem(key, value) {
+ await this.#setQueryResults(key, JSON.parse(value));
+ }
+
+ async removeItem() {
+ // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993
+
+ await Promise.all(
+ db.tables.map((table) => {
+ return table.clear();
+ }),
+ );
+ }
+
+ async #setQueryResults(queryId, results) {
+ await Promise.all(
+ Object.keys(results).map((id) => {
+ const objectType = id.split(':')[0];
+ if (objectType === 'ROOT_QUERY') {
+ return db.table('queries').put(results[id], queryId);
+ }
+ const key = objectType.toLowerCase();
+ const tableExists = db.tables.some((table) => table.name === key);
+ if (tableExists) {
+ return db.table(key).put(results[id], id);
+ }
+ return new Promise((resolve) => {
+ resolve();
+ });
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js
new file mode 100644
index 00000000000..cda30ff9d42
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/local_db.js
@@ -0,0 +1,14 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import Dexie from 'dexie';
+
+export const db = new Dexie('GLLocalCache');
+db.version(1).stores({
+ pages: 'url, timestamp',
+ queries: '',
+ project: 'id',
+ group: 'id',
+ usercore: 'id',
+ issue: 'id, state, title',
+ label: 'id, title',
+ milestone: 'id',
+});
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index c0e923b2670..2e6fcbea80d 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,7 +1,7 @@
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
-import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist';
+import { persistCache } from 'apollo3-cache-persist';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import possibleTypes from '~/graphql_shared/possible_types.json';
@@ -53,6 +53,15 @@ export const typePolicies = {
TreeEntry: {
keyFields: ['webPath'],
},
+ Subscription: {
+ fields: {
+ aiCompletionResponse: {
+ read(value) {
+ return value ?? null;
+ },
+ },
+ },
+ },
};
export const stripWhitespaceFromQuery = (url, path) => {
@@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', {
},
});
-export default (resolvers = {}, config = {}) => {
+function createApolloClient(resolvers = {}, config = {}) {
const {
baseUrl,
batchMax = 10,
@@ -113,7 +122,6 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
path = '/api/graphql',
useGet = false,
- localCacheKey = null,
} = config;
let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
@@ -171,6 +179,7 @@ export default (resolvers = {}, config = {}) => {
config: {
url: httpResponse.url,
operationName: operation.operationName,
+ method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request
},
headers: {
'x-request-id': httpResponse.headers.get('x-request-id'),
@@ -237,16 +246,6 @@ export default (resolvers = {}, config = {}) => {
},
});
- if (localCacheKey) {
- persistCacheSync({
- cache: newCache,
- // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
- debug: process.env.NODE_ENV === 'development',
- storage: new LocalStorageWrapper(window.localStorage),
- persistenceMapper,
- });
- }
-
ac = new ApolloClient({
typeDefs,
link: appLink,
@@ -262,5 +261,42 @@ export default (resolvers = {}, config = {}) => {
acs.push(ac);
- return ac;
+ return { client: ac, cache: newCache };
+}
+
+export async function createApolloClientWithCaching(resolvers = {}, config = {}) {
+ const { localCacheKey = null } = config;
+ const { client, cache } = createApolloClient(resolvers, config);
+
+ if (localCacheKey) {
+ let storage;
+
+ // Test that we can use IndexedDB. If not, no persisting for you!
+ try {
+ const { IndexedDBPersistentStorage } = await import(
+ /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage'
+ );
+
+ storage = await IndexedDBPersistentStorage.create();
+ } catch (error) {
+ return client;
+ }
+
+ await persistCache({
+ cache,
+ // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
+ debug: process.env.NODE_ENV === 'development',
+ storage,
+ key: localCacheKey,
+ persistenceMapper,
+ });
+ }
+
+ return client;
+}
+
+export default (resolvers = {}, config = {}) => {
+ const { client } = createApolloClient(resolvers, config);
+
+ return client;
};
diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js
index c72561ce69d..60b46989375 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -12,8 +12,9 @@ const drawDiagram = (source) => {
// eslint-disable-next-line no-unsanitized/property
element.innerHTML = svgCode;
- const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
- const width = parseInt(element.firstElementChild.style.maxWidth, 10);
+ element.firstElementChild.removeAttribute('height');
+ const { height, width } = element.firstElementChild.getBoundingClientRect();
+
setIframeRenderedSize(height, width);
};
mermaid.mermaidAPI.render('mermaid', source, insertSvg);
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js
index 7da3bab0a4b..520d7f627f6 100644
--- a/app/assets/javascripts/lib/utils/chart_utils.js
+++ b/app/assets/javascripts/lib/utils/chart_utils.js
@@ -1,3 +1,6 @@
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { __ } from '~/locale';
+
const commonTooltips = () => ({
mode: 'x',
intersect: false,
@@ -98,3 +101,38 @@ export const firstAndLastY = (data) => {
return [firstY, lastY];
};
+
+const toolboxIconSvgPath = async (name) => {
+ return `path://${await getSvgIconPathContent(name)}`;
+};
+
+export const getToolboxOptions = async () => {
+ const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath);
+
+ try {
+ const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises);
+
+ return {
+ toolbox: {
+ feature: {
+ dataZoom: {
+ icon: { zoom: marqueeSelectionPath, back: redoPath },
+ },
+ restore: {
+ icon: repeatPath,
+ },
+ saveAsImage: {
+ icon: downloadPath,
+ },
+ },
+ },
+ };
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line no-console
+ console.warn(__('SVG could not be rendered correctly: '), e);
+ }
+
+ return {};
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
new file mode 100644
index 00000000000..64c77bf1080
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
@@ -0,0 +1,13 @@
+import { stringifyTime, parseSeconds } from './date_format_utility';
+
+/**
+ * Formats seconds into a human readable value of elapsed time,
+ * optionally limiting it to hours.
+ * @param {Number} seconds Seconds to format
+ * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours
+ * @return {String} Provided seconds in human readable elapsed time format
+ */
+export const formatTimeSpent = (seconds, limitToHours) => {
+ const negative = seconds < 0;
+ return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours }));
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index c1081239544..f9a70371680 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -2,3 +2,4 @@ export * from './datetime/timeago_utility';
export * from './datetime/date_format_utility';
export * from './datetime/date_calculation_utility';
export * from './datetime/pikaday_utility';
+export * from './datetime/time_spent_utility';
diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js
index 4cea4257e7b..febf83a4d38 100644
--- a/app/assets/javascripts/lib/utils/error_message.js
+++ b/app/assets/javascripts/lib/utils/error_message.js
@@ -1,20 +1,15 @@
-export const USER_FACING_ERROR_MESSAGE_PREFIX = 'UF:';
-
-const getMessageFromError = (error = '') => {
- return error.message || error;
-};
-
-export const parseErrorMessage = (error = '') => {
- const messageString = getMessageFromError(error);
-
- if (messageString.startsWith(USER_FACING_ERROR_MESSAGE_PREFIX)) {
- return {
- message: messageString.replace(USER_FACING_ERROR_MESSAGE_PREFIX, '').trim(),
- userFacing: true,
- };
- }
- return {
- message: messageString,
- userFacing: false,
- };
+/**
+ * Utility to parse an error object returned from API.
+ *
+ *
+ * @param { Object } error - An error object directly from API response
+ * @param { string } error.message - The error message, returned from API.
+ * @param { string } defaultMessage - Default user-facing error message
+ * @returns { string } - A transformed user-facing error message, or defaultMessage
+ */
+export const parseErrorMessage = (error = {}, defaultMessage = '') => {
+ const messageString = error.message || '';
+ return messageString.startsWith(window.gon.uf_error_prefix)
+ ? messageString.replace(window.gon.uf_error_prefix, '').trim()
+ : defaultMessage;
};
diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js
index bd47f10b3ac..7cfcd11ece9 100644
--- a/app/assets/javascripts/lib/utils/keys.js
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -1,3 +1,7 @@
export const ESC_KEY = 'Escape';
export const ENTER_KEY = 'Enter';
export const BACKSPACE_KEY = 'Backspace';
+export const ARROW_DOWN_KEY = 'ArrowDown';
+export const ARROW_UP_KEY = 'ArrowUp';
+export const END_KEY = 'End';
+export const HOME_KEY = 'Home';
diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js
new file mode 100644
index 00000000000..2807911c9bb
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/secret_detection.js
@@ -0,0 +1,45 @@
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { s__, __ } from '~/locale';
+
+export const i18n = {
+ defaultPrompt: s__(
+ 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?',
+ ),
+ descriptionPrompt: s__(
+ 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?',
+ ),
+ primaryBtnText: __('Proceed'),
+};
+
+const sensitiveDataPatterns = [
+ {
+ name: 'GitLab Personal Access Token',
+ regex: 'glpat-[0-9a-zA-Z_-]{20}',
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Feed Token',
+ regex: 'feed_token=[0-9a-zA-Z_-]{20}',
+ },
+];
+
+export const containsSensitiveToken = (message) => {
+ for (const rule of sensitiveDataPatterns) {
+ const regex = new RegExp(rule.regex, 'gi');
+ if (regex.test(message)) {
+ return true;
+ }
+ }
+ return false;
+};
+
+export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) {
+ const confirmed = await confirmAction(prompt, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: i18n.primaryBtnText,
+ });
+ if (!confirmed) {
+ return false;
+ }
+ return true;
+}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 2d5e9bc91f2..a2873622682 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -371,7 +371,7 @@ export function insertMarkdownText({
});
}
-function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
+export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
const $textArea = $(textArea);
textArea = $textArea.get(0);
const text = $textArea.val();
@@ -627,10 +627,9 @@ export function addMarkdownListeners(form) {
});
const $allToolbarBtns = $(form)
- .off('click', '.js-md, .saved-replies-dropdown li')
- .on('click', '.js-md, .saved-replies-dropdown li', function () {
- const $savedReplyContent = $('.js-saved-reply-content', this);
- const $toolbarBtn = $savedReplyContent.length ? $savedReplyContent : $(this);
+ .off('click', '.js-md')
+ .on('click', '.js-md', function () {
+ const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
});
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
new file mode 100644
index 00000000000..fd08d34a80e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
@@ -0,0 +1,78 @@
+import Vue from 'vue';
+import { createApolloProvider } from '@vue/apollo-option';
+import { ApolloMutation } from '@vue/apollo-components';
+
+export { ApolloMutation };
+
+const installed = new WeakMap();
+
+function callLifecycle(hookName, ...extraArgs) {
+ const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$;
+ if (!addedMixins) {
+ return [];
+ }
+
+ return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs));
+}
+
+function createMixinForLateInit({ install, shouldInstall }) {
+ return {
+ created() {
+ callLifecycle.call(this, 'created');
+ },
+ // @vue/compat normalizez lifecycle hook names so there is no error here
+ destroyed() {
+ callLifecycle.call(this, 'unmounted');
+ },
+
+ data(...args) {
+ const extraData = callLifecycle.call(this, 'data', ...args);
+ if (!extraData.length) {
+ return {};
+ }
+
+ return Object.assign({}, ...extraData);
+ },
+
+ beforeCreate() {
+ if (shouldInstall(this)) {
+ const { mixins } = this.$.appContext;
+ const globalMixinsBeforeInit = new Set(mixins);
+ install(this);
+
+ this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m));
+
+ callLifecycle.call(this, 'beforeCreate');
+ }
+ },
+ };
+}
+
+export default class VueCompatApollo {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createApolloProvider(...args);
+ }
+
+ static install() {
+ Vue.mixin(
+ createMixinForLateInit({
+ shouldInstall: (vm) =>
+ vm.$options.apolloProvider &&
+ !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider),
+ install: (vm) => {
+ const { app } = vm.$.appContext;
+ const { apolloProvider } = vm.$options;
+
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+
+ installed.get(app).add(apolloProvider);
+
+ vm.$.appContext.app.use(vm.$options.apolloProvider);
+ },
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
new file mode 100644
index 00000000000..006ed920ef0
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -0,0 +1,105 @@
+import Vue from 'vue';
+import {
+ createRouter,
+ createMemoryHistory,
+ createWebHistory,
+ createWebHashHistory,
+} from 'vue-router-vue3';
+
+const mode = (value, options) => {
+ if (!value) return null;
+ let history;
+ // eslint-disable-next-line default-case
+ switch (value) {
+ case 'history':
+ history = createWebHistory(options.base);
+ break;
+ case 'hash':
+ history = createWebHashHistory();
+ break;
+ case 'abstract':
+ history = createMemoryHistory();
+ break;
+ }
+ return { history };
+};
+
+const base = () => null;
+
+const toNewCatchAllPath = (path) => {
+ if (path === '*') return '/:pathMatch(.*)*';
+ return path;
+};
+
+const routes = (value) => {
+ if (!value) return null;
+ const newRoutes = value.reduce(function handleRoutes(acc, route) {
+ const newRoute = {
+ ...route,
+ path: toNewCatchAllPath(route.path),
+ };
+ if (route.children) {
+ newRoute.children = route.children.reduce(handleRoutes, []);
+ }
+ acc.push(newRoute);
+ return acc;
+ }, []);
+ return { routes: newRoutes };
+};
+
+const scrollBehavior = (value) => {
+ return {
+ scrollBehavior(...args) {
+ const { x, y, left, top } = value(...args);
+ return { left: x || left, top: y || top };
+ },
+ };
+};
+
+const transformers = {
+ mode,
+ base,
+ routes,
+ scrollBehavior,
+};
+
+const transformOptions = (options) => {
+ const defaultConfig = {
+ routes: null,
+ history: createWebHashHistory(),
+ };
+ return Object.keys(options).reduce((acc, key) => {
+ const value = options[key];
+ if (key in transformers) {
+ Object.assign(acc, transformers[key](value, options));
+ } else {
+ acc[key] = value;
+ }
+ return acc;
+ }, defaultConfig);
+};
+
+const installed = new WeakMap();
+
+export default class VueRouterCompat {
+ constructor(options) {
+ // eslint-disable-next-line no-constructor-return
+ return createRouter(transformOptions(options));
+ }
+
+ static install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { router } = this.$options;
+ if (router && !installed.get(app)?.has(router)) {
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+ installed.get(app).add(router);
+ this.$.appContext.app.use(this.$options.router);
+ }
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
new file mode 100644
index 00000000000..ff94ff3d04a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import {
+ createStore,
+ mapState,
+ mapGetters,
+ mapActions,
+ mapMutations,
+ createNamespacedHelpers,
+} from 'vuex-vue3';
+
+export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers };
+
+const installedStores = new WeakMap();
+
+export default {
+ Store: class VuexCompatStore {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createStore(...args);
+ }
+ },
+
+ install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { store } = this.$options;
+ if (store && !installedStores.get(app)?.has(store)) {
+ if (!installedStores.has(app)) {
+ installedStores.set(app, new WeakSet());
+ }
+ installedStores.get(app).add(store);
+ this.$.appContext.app.use(this.$options.store);
+ }
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js
new file mode 100644
index 00000000000..f0579b5886d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js
@@ -0,0 +1,24 @@
+import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility';
+
+/**
+ * Takes a project path and optional file path and branch
+ * and then redirects the user to the web IDE.
+ *
+ * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight)
+ * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md)
+ * @param {string} branch - optional branch to open the IDE, defaults to 'main'
+ */
+
+export const openWebIDE = (projectPath, filePath, branch = 'main') => {
+ if (!projectPath) {
+ throw new TypeError('projectPath parameter is required');
+ }
+
+ const pathnameSegments = [projectPath, 'edit', branch, '-'];
+
+ if (filePath) {
+ pathnameSegments.push(filePath);
+ }
+
+ visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`));
+};
diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs
index c2c63777001..f7790cadc48 100644
--- a/app/assets/javascripts/locale/ensure_single_line.cjs
+++ b/app/assets/javascripts/locale/ensure_single_line.cjs
@@ -1,5 +1,3 @@
-/* eslint-disable import/no-commonjs */
-
const SPLIT_REGEX = /\s*[\r\n]+\s*/;
/**
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index a1539aba786..fd002e29afc 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -89,9 +89,11 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
- if (!gon.use_new_navigation) initTopNav();
+ if (!gon.use_new_navigation) {
+ initTopNav();
+ initTodoToggle();
+ }
initBreadcrumbs();
- initTodoToggle();
initPrefetchLinks('.js-prefetch-document');
initLogoAnimation();
initServicePingConsent();
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 76b286f94ad..685482a76de 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,5 +1,6 @@
<script>
import { mapState } from 'vuex';
+import { __ } from '~/locale';
import {
getParameterByName,
setUrlParams,
@@ -46,6 +47,10 @@ export default {
return false;
}
+ if (token.type === 'user_type' && !gon.features?.serviceAccountsCrud) {
+ return false;
+ }
+
return this.filteredSearchBar.tokens?.includes(token.type);
});
},
@@ -94,6 +99,14 @@ export default {
};
}
} else {
+ // Remove this block after this issue is closed: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159
+ if (value.data === __('Service account')) {
+ return {
+ ...accumulator,
+ [type]: 'service_account',
+ };
+ }
+
return {
...accumulator,
[type]: value.data,
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index e066b023fbb..a85bb09e17b 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -81,19 +81,18 @@ export default {
return;
}
- this.updateMemberRole({
- memberId: this.member.id,
- accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
- })
- .then(() => {
- this.$toast.show(s__('Members|Role updated successfully.'));
- })
- .catch((error) => {
- Sentry.captureException(error);
- })
- .finally(() => {
- this.busy = false;
+ try {
+ await this.updateMemberRole({
+ memberId: this.member.id,
+ accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
});
+
+ this.$toast.show(s__('Members|Role updated successfully.'));
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.busy = false;
+ }
},
},
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index d55e942dafa..124b14a9845 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -13,7 +13,7 @@ import axios from './lib/utils/axios_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
-import { __ } from './locale';
+import { __, s__ } from './locale';
import syntaxHighlight from './syntax_highlight';
// MergeRequestTabs
@@ -316,7 +316,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
toggleLoader(false);
- createAlert({ message: __('MergeRequest|Failed to load the page') });
+ createAlert({ message: s__('MergeRequest|Failed to load the page') });
});
}
diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js
index f90fdb04923..9d210f7a6ec 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -64,8 +64,6 @@ export function initDeleteMilestoneModal() {
if (!successful) {
button.removeAttribute('disabled');
}
-
- button.querySelector('.js-loading-icon').classList.add('hidden');
};
const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
@@ -75,7 +73,6 @@ export function initDeleteMilestoneModal() {
`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`,
);
button.setAttribute('disabled', '');
- button.querySelector('.js-loading-icon').classList.remove('hidden');
eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
};
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
new file mode 100644
index 00000000000..4c0f99cf62c
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
@@ -0,0 +1,98 @@
+<script>
+import {
+ GlModal,
+ GlDropdown,
+ GlTooltipDirective,
+ GlDropdownItem,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ props: {
+ deletePath: {
+ type: String,
+ required: true,
+ },
+ deleteConfirmationText: {
+ type: String,
+ required: true,
+ },
+ actionPrimaryText: {
+ type: String,
+ required: true,
+ },
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleteModalVisible: false,
+ modal: {
+ id: 'ml-experiments-delete-modal',
+ deleteConfirmation: this.deleteConfirmationText,
+ actionPrimary: {
+ text: this.actionPrimaryText,
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
+ };
+ },
+ methods: {
+ confirmDelete() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ right
+ category="tertiary"
+ :aria-label="__('More actions')"
+ icon="ellipsis_v"
+ no-caret
+ >
+ <gl-dropdown-item
+ v-gl-modal-directive="modal.id"
+ :aria-label="actionPrimaryText"
+ variant="danger"
+ >
+ {{ actionPrimaryText }}
+
+ <form ref="deleteForm" method="post" :action="deletePath">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+
+ <gl-modal
+ :modal-id="modal.id"
+ :title="modalTitle"
+ :action-primary="modal.actionPrimary"
+ :action-cancel="modal.actionCancel"
+ @primary="confirmDelete"
+ >
+ <p>
+ {{ deleteConfirmationText }}
+ </p>
+ </gl-modal>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</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 3c765de92a2..23b58543f11 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
@@ -2,6 +2,7 @@
import { GlLink } from '@gitlab/ui';
import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
import {
TITLE_LABEL,
INFO_LABEL,
@@ -12,12 +13,16 @@ import {
PARAMETERS_LABEL,
METRICS_LABEL,
METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
} from './translations';
export default {
name: 'MlCandidatesShow',
components: {
IncubationAlert,
+ DeleteButton,
GlLink,
},
props: {
@@ -36,6 +41,9 @@ export default {
PARAMETERS_LABEL,
METRICS_LABEL,
METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
},
computed: {
sections() {
@@ -67,11 +75,22 @@ export default {
:link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
/>
- <h3>
- {{ $options.i18n.TITLE_LABEL }}
- </h3>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <h1 class="page-title gl-font-size-h-display flex-fill">
+ {{ $options.i18n.TITLE_LABEL }}
+ </h1>
- <table class="candidate-details">
+ <delete-button
+ :delete-path="candidate.info.path"
+ :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE"
+ :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL"
+ :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE"
+ />
+ </div>
+ </div>
+
+ <table class="candidate-details gl-w-full">
<tbody>
<tr class="divider"></tr>
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 caad145873e..5f7714aa0c0 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
@@ -9,3 +9,8 @@ export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts');
export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters');
export const METRICS_LABEL = s__('MlExperimentTracking|Metrics');
export const METADATA_LABEL = s__('MlExperimentTracking|Metadata');
+export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.',
+);
+export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate');
+export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue
new file mode 100644
index 00000000000..92c662fedf1
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/components/experiment_header.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import { __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+export default {
+ name: 'ExperimentHeader',
+ components: {
+ DeleteButton,
+ GlButton,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ deleteInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ downloadCsv() {
+ const currentPath = window.location.pathname;
+ const currentSearch = window.location.search;
+
+ visitUrl(`${currentPath}.csv${currentSearch}`);
+ },
+ },
+ i18n: {
+ downloadAsCsvLabel: __('Download as CSV'),
+ },
+};
+</script>
+
+<template>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <h1 class="page-title gl-font-size-h-display flex-fill">{{ title }}</h1>
+
+ <gl-button @click="downloadCsv">{{ $options.i18n.downloadAsCsvLabel }}</gl-button>
+
+ <delete-button v-bind="deleteInfo" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
index ca0a42fda10..acb5fc7cad2 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
@@ -12,6 +12,7 @@ import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import ExperimentHeader from './components/experiment_header.vue';
import {
LIST_KEY_CREATED_AT,
BASE_SORT_FIELDS,
@@ -30,8 +31,13 @@ export default {
IncubationAlert,
RegistrySearch,
KeysetPagination,
+ ExperimentHeader,
},
props: {
+ experiment: {
+ type: Object,
+ required: true,
+ },
candidates: {
type: Array,
required: true,
@@ -124,6 +130,14 @@ export default {
hasItems() {
return this.candidates.length > 0;
},
+ deleteButtonInfo() {
+ return {
+ deletePath: this.experiment.path,
+ deleteConfirmationText: translations.DELETE_EXPERIMENT_CONFIRMATION_MESSAGE,
+ actionPrimaryText: translations.DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL,
+ modalTitle: translations.DELETE_EXPERIMENT_MODAL_TITLE,
+ };
+ },
},
methods: {
submitFilters() {
@@ -157,6 +171,8 @@ export default {
:link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
/>
+ <experiment-header :title="experiment.name" :delete-info="deleteButtonInfo" />
+
<registry-search
:filters="filters"
:sorting="sorting"
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
index 63b0d902b72..5c34a66921d 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
@@ -14,3 +14,8 @@ export const EMPTY_STATE_DESCRIPTION_LABEL = s__(
'MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client.',
);
export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No candidates');
+export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this experiment will also delete its candidates and their associated metadata.',
+);
+export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment');
+export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?');
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 4fdc08487f2..32e85262882 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -40,7 +40,7 @@ function prometheusMetricQueryParams(timeRange) {
* Extract error messages from API or HTTP request errors.
*
* - API errors are in `error.response.data.message`
- * - HTTP (axios) errors are in `error.messsage`
+ * - HTTP (axios) errors are in `error.message`
*
* @param {Object} error
* @returns {String} User friendly error message
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 0d849e1a2d8..5f4d2703d21 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -97,7 +97,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -107,13 +106,13 @@ export const generateLinkToChartOptions = (chartLink) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'generate_link_to_cluster_metric_chart'
: 'generate_link_to_metrics_chart';
- return { category, action, label: 'Chart link', property: chartLink };
+ return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings
};
/**
@@ -125,13 +124,13 @@ export const downloadCSVOptions = (title) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'download_csv_of_cluster_metric_chart'
: 'download_csv_of_metrics_dashboard_chart';
- return { category, action, label: 'Chart title', property: title };
+ return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings
};
/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 2488c8aee9c..d537117d962 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -41,7 +41,7 @@ export default () => {
apolloProvider,
provide: {
reportAbusePath: notesDataset.reportAbusePath,
- newSavedRepliesPath: notesDataset.savedRepliesNewPath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/actions.js b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
new file mode 100644
index 00000000000..4f8c3bb7f20
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
@@ -0,0 +1,5 @@
+import * as types from './mutation_types';
+
+export const setDrawer = ({ commit }, data) => {
+ return commit(types.default.SET_DRAWER, data);
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/getters.js b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
new file mode 100644
index 00000000000..dd61bc900fa
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
@@ -0,0 +1 @@
+export const activeDrawer = (state) => state.activeDrawer;
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/index.js b/app/assets/javascripts/mr_notes/stores/drawer/index.js
new file mode 100644
index 00000000000..c4a7eacb78a
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/index.js
@@ -0,0 +1,13 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+export default () => ({
+ namespaced: true,
+ state: {
+ activeDrawer: {},
+ },
+ mutations,
+ actions,
+ getters,
+});
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
new file mode 100644
index 00000000000..5fe8a9ba20d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
@@ -0,0 +1,3 @@
+export default {
+ SET_DRAWER: 'SET_DRAWER',
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutations.js b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
new file mode 100644
index 00000000000..eee43f2b316
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
@@ -0,0 +1,7 @@
+import types from './mutation_types';
+
+export default {
+ [types.SET_DRAWER](state, drawer) {
+ Object.assign(state, { activeDrawer: drawer });
+ },
+};
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 7527c685c71..1f8e61beff0 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -4,6 +4,7 @@ import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'
import diffsModule from '~/diffs/store/modules';
import notesModule from '~/notes/stores/modules';
import mrPageModule from './modules';
+import findingsDrawer from './drawer';
Vue.use(Vuex);
@@ -12,6 +13,7 @@ export const createModules = () => ({
notes: notesModule(),
diffs: diffsModule(),
batchComments: batchCommentsModule(),
+ findingsDrawer: findingsDrawer(),
});
export const createStore = () =>
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index ca6232fa4c4..5eb5e5b9b90 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -7,7 +7,7 @@ import Tracking from '~/tracking';
export default {
i18n: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
+ badgeLabel: s__('NorthstarNavigation|Beta'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'),
toggleLabel: s__('NorthstarNavigation|Toggle new navigation'),
@@ -51,7 +51,7 @@ export default {
Tracking.event(undefined, 'click_toggle', {
label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta',
- property: this.enabled ? 'navigation' : 'navigation_top',
+ property: this.enabled ? 'nav_user_menu' : 'navigation_top',
});
window.location.reload();
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index a7c2e572037..9d07f435620 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -10,12 +10,12 @@ export default class NewBranchForm {
}
addBinding() {
- this.name.addEventListener('blur', this.validate);
+ this.name.addEventListener('change', this.validate);
}
init() {
if (this.name != null && this.name.value.length > 0) {
- const event = new CustomEvent('blur');
+ const event = new CustomEvent('change');
this.name.dispatchEvent(event);
}
}
@@ -77,6 +77,7 @@ export default class NewBranchForm {
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
this.branchNameError.textContent = errors.join(', ');
+ this.name.focus();
}
}
}
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index 037be8467cb..c76ffce9168 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-return-assign */
export default class NewCommitForm {
constructor(form) {
this.form = form;
@@ -21,6 +20,6 @@ export default class NewCommitForm {
this.createMergeRequestContainer.hide();
this.createMergeRequest.prop('checked', false);
}
- return (this.wasDifferent = different);
+ this.wasDifferent = different;
}
}
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 64e801a7516..211a12208c1 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -51,7 +51,7 @@ export default {
language="python"
:code="code"
:max-height="maxHeight"
- class="gl-border"
+ class="gl-border gl-p-4!"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue
new file mode 100644
index 00000000000..4fe02ee6edf
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue
@@ -0,0 +1,46 @@
+<script>
+import JSONTable from '~/behaviors/components/json_table.vue';
+import Prompt from '../prompt.vue';
+import { convertHtmlTableToJson } from './dataframe_util';
+
+export default {
+ name: 'DataframeOutput',
+ components: {
+ Prompt,
+ JSONTable,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ showOutput() {
+ return this.index === 0;
+ },
+ dataframeAsJSONTable() {
+ return {
+ ...convertHtmlTableToJson(this.rawCode),
+ caption: '',
+ hasFilter: true,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" :show-output="showOutput" />
+ <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
new file mode 100644
index 00000000000..2fdaaced0b9
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
@@ -0,0 +1,44 @@
+import { sanitize } from '~/lib/dompurify';
+
+/**
+ * Converts a dataframe in the output of a Jupyter Notebook cell to a json object
+ *
+ * @param {string} input - the dataframe
+ * @param {DOMParser} parser - the html parser
+ * @returns {Object} The converted JSON object with an `items` property containing the rows.
+ */
+export function convertHtmlTableToJson(input, domParser) {
+ const parser = domParser || new DOMParser();
+ const htmlDoc = parser.parseFromString(sanitize(input), 'text/html');
+
+ if (!htmlDoc) return { fields: [], items: [] };
+
+ const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map(
+ (head) => head.innerText,
+ );
+
+ if (!columnNames) return { fields: [], items: [] };
+
+ const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) =>
+ [...row.querySelectorAll('td')].map((item) => item.innerText),
+ );
+
+ return {
+ fields: columnNames.map((column) => ({
+ key: column === '' ? 'index' : column,
+ label: column,
+ sortable: true,
+ })),
+ items: itemValues.map((values, itemIndex) => ({
+ index: itemIndex,
+ ...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])),
+ })),
+ };
+}
+
+export function isDataframe(output) {
+ const htmlData = output.data['text/html'];
+ if (!htmlData) return false;
+
+ return htmlData.slice(0, 20).some((line) => line.includes('dataframe'));
+}
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 22bcb5dd66a..0437b85913b 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -5,6 +5,8 @@ import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
import MarkdownOutput from './markdown.vue';
import ErrorOutput from './error.vue';
+import DataframeOutput from './dataframe.vue';
+import { isDataframe } from './dataframe_util';
const TEXT_MARKDOWN = 'text/markdown';
const ERROR_OUTPUT_TYPE = 'error';
@@ -66,6 +68,8 @@ export default {
return ImageOutput;
} else if (output.data['image/jpeg']) {
return ImageOutput;
+ } else if (isDataframe(output)) {
+ return DataframeOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['text/latex']) {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4bcddb260e1..6794f838c84 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -7,6 +7,7 @@ import { createAlert } from '~/alert';
import { badgeState } from '~/issuable/components/status_box.vue';
import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
capitalizeFirstCharacter,
convertToCamelCase,
@@ -81,6 +82,9 @@ export default {
'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noteableDisplayName() {
const displayNameMap = {
[constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue,
@@ -224,7 +228,7 @@ export default {
handleSaveDraft() {
this.handleSave({ isDraft: true });
},
- handleSave({ withIssueAction = false, isDraft = false } = {}) {
+ async handleSave({ withIssueAction = false, isDraft = false } = {}) {
this.errors = [];
if (this.note.length) {
@@ -246,6 +250,13 @@ export default {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
+ if (containsSensitiveToken(this.note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return;
+ }
+ }
+
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.stopPolling();
@@ -363,6 +374,7 @@ export default {
:form-field-props="formFieldProps"
:autosave-key="autosaveKey"
:disabled="isSubmitting"
+ :autocomplete-data-sources="autocompleteDataSources"
supports-quick-actions
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleEnter()"
@@ -399,10 +411,10 @@ export default {
{{ $options.i18n.internal }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
- name="question"
+ name="question-o"
:size="16"
:title="$options.i18n.internalVisibility"
- class="gl-text-gray-500"
+ class="gl-text-blue-500"
/>
</gl-form-checkbox>
<comment-type-dropdown
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 37935e9c3c6..ba5ffc60917 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -157,7 +157,7 @@ export default {
v-if="resolveAllDiscussionsIssuePath && !allResolved"
:href="resolveAllDiscussionsIssuePath"
>
- {{ __('Create issue to resolve all threads') }}
+ {{ __('Resolve all with new issue') }}
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 39b3df899a5..d02327a37a7 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -28,7 +28,9 @@ export default {
class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"
data-qa-selector="discussion_filter_container"
>
- <div class="timeline-icon d-none d-lg-flex">
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
<gl-icon name="comment" />
</div>
<div class="timeline-content gl-pl-8">
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 89cd252b94b..bce17aebd64 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -21,7 +21,7 @@ export default {
editCommentLabel: __('Edit comment'),
deleteCommentLabel: __('Delete comment'),
moreActionsLabel: __('More actions'),
- reportAbuse: __('Report abuse to administrator'),
+ reportAbuse: __('Report abuse'),
},
name: 'NoteActions',
components: {
@@ -367,7 +367,7 @@ export default {
@click="closeTooltip"
/>
<!-- eslint-enable @gitlab/vue-no-data-toggle -->
- <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
+ <ul class="dropdown-menu more-actions-dropdown dropdown-menu-right">
<gl-dropdown-item
v-if="canEdit"
class="js-note-edit gl-sm-display-none!"
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 21841680cab..9c04a72375b 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -35,9 +35,6 @@ export default {
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
- addButtonClass() {
- return this.isAuthoredByMe ? 'js-user-authored' : '';
- },
},
methods: {
...mapActions(['toggleAwardRequest']),
@@ -64,7 +61,6 @@ export default {
:awards="awards"
:can-award-emoji="canAwardEmoji"
:current-user-id="getUserData.id"
- :add-button-class="addButtonClass"
@award="handleAward($event)"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index eef011db7d2..b4e5129ca0e 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -5,7 +5,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import autosave from '../mixins/autosave';
import NoteAttachment from './note_attachment.vue';
import NoteAwardsList from './note_awards_list.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -22,7 +21,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [autosave],
props: {
note: {
type: Object,
@@ -96,21 +94,9 @@ export default {
},
mounted() {
this.renderGFM();
-
- if (this.isEditing) {
- this.initAutoSave(this.note);
- }
},
updated() {
this.renderGFM();
-
- if (this.isEditing) {
- if (!this.autosave) {
- this.initAutoSave(this.note);
- } else {
- this.setAutoSave();
- }
- }
},
methods: {
...mapActions([
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b6ede10d02b..34ae0c7ffc1 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,10 +1,10 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -15,13 +15,14 @@ export default {
i18n: COMMENT_FORM,
name: 'NoteForm',
components: {
- MarkdownField,
+ MarkdownEditor,
CommentFieldLayout,
GlButton,
GlSprintf,
GlLink,
+ GlFormCheckbox,
},
- mixins: [issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()],
props: {
noteBody: {
type: String,
@@ -95,20 +96,22 @@ export default {
},
},
data() {
- let updatedNoteBody = this.noteBody;
-
- if (!updatedNoteBody && this.autosaveKey) {
- updatedNoteBody = getDraft(this.autosaveKey) || '';
- }
-
return {
- updatedNoteBody,
+ updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: this.resolveDiscussion,
isUnresolving: !this.resolveDiscussion,
resolveAsThread: true,
isSubmittingWithKeydown: false,
+ formFieldProps: {
+ id: 'note_note',
+ name: 'note[note]',
+ 'aria-label': __('Reply to comment'),
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form',
+ 'data-qa-selector': 'reply_field',
+ },
};
},
computed: {
@@ -123,6 +126,9 @@ export default {
withBatchComments: (state) => state.batchComments?.withBatchComments,
}),
...mapGetters('batchComments', ['hasDrafts']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
showBatchCommentsActions() {
return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
},
@@ -135,11 +141,6 @@ export default {
.some((n) => n.current_user?.can_resolve_discussion) || this.isDraft
);
},
- textareaPlaceholder() {
- return this.discussionNote?.internal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
noteHash() {
if (this.noteId) {
return `#note_${this.noteId}`;
@@ -214,6 +215,9 @@ export default {
placeholder: { link: ['startTag', 'endTag'] },
};
},
+ enableContentEditor() {
+ return Boolean(this.glFeatures.contentEditorOnIssues);
+ },
},
watch: {
noteBody() {
@@ -225,7 +229,7 @@ export default {
},
},
mounted() {
- this.$refs.textarea.focus();
+ this.updatePlaceholder();
},
methods: {
...mapActions(['toggleResolveNote']),
@@ -252,19 +256,21 @@ export default {
},
cancelHandler(shouldConfirm = false) {
// check if any dropdowns are active before sending the cancelation event
- if (!this.$refs.textarea.classList.contains('at-who-active')) {
+ if (
+ !this.$refs.markdownEditor.$el
+ .querySelector('textarea')
+ ?.classList.contains('at-who-active')
+ ) {
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
}
},
- onInput() {
- if (this.isSubmittingWithKeydown) {
- return;
- }
-
- if (this.autosaveKey) {
- const { autosaveKey, updatedNoteBody: text } = this;
- updateDraft(autosaveKey, text);
- }
+ updatePlaceholder() {
+ this.formFieldProps.placeholder = this.discussionNote?.internal
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
+ onInput(value) {
+ this.updatedNoteBody = value;
},
handleKeySubmit() {
if (this.showBatchCommentsActions) {
@@ -273,6 +279,7 @@ export default {
this.isSubmittingWithKeydown = true;
this.handleUpdate();
}
+ this.updatedNoteBody = '';
},
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
@@ -333,53 +340,47 @@ export default {
:noteable-data="getNoteableData"
:is-internal-note="discussion.internal"
>
- <markdown-field
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="enableContentEditor"
+ :value="updatedNoteBody"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:lines="lines"
- :note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
+ :note="discussionNote"
+ :form-field-props="formFieldProps"
:show-suggest-popover="showSuggestPopover"
- :textarea-value="updatedNoteBody"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :autosave-key="autosaveKey"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :disabled="isSubmitting"
+ supports-quick-actions
+ autofocus
+ @keydown.meta.enter="handleKeySubmit()"
+ @keydown.ctrl.enter="handleKeySubmit()"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
+ @input="onInput"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
- >
- <template #textarea>
- <textarea
- id="note_note"
- ref="textarea"
- v-model="updatedNoteBody"
- :disabled="isSubmitting"
- data-supports-quick-actions="true"
- name="note[note]"
- class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
- data-qa-selector="reply_field"
- dir="auto"
- :aria-label="__('Reply to comment')"
- :placeholder="textareaPlaceholder"
- @keydown.meta.enter="handleKeySubmit()"
- @keydown.ctrl.enter="handleKeySubmit()"
- @keydown.exact.up="editMyLastNote()"
- @keydown.exact.esc="cancelHandler(true)"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
<label>
<template v-if="discussionResolved">
- <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" />
- {{ __('Unresolve thread') }}
+ <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox">
+ {{ __('Unresolve thread') }}
+ </gl-form-checkbox>
</template>
<template v-else>
- <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" />
- {{ __('Resolve thread') }}
+ <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox">
+ {{ __('Resolve thread') }}
+ </gl-form-checkbox>
</template>
</label>
</p>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 60ae573bae7..3375e366ecf 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -11,6 +11,7 @@ import { s__, __, sprintf } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -207,12 +208,21 @@ export default {
this.isReplying = false;
clearDraft(this.autosaveKey);
}),
- saveReply(noteText, form, callback) {
+ async saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
callback();
return;
}
+
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 80025d6f98a..ae2f94a5a80 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -13,6 +13,7 @@ import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -294,10 +295,9 @@ export default {
this.isRequesting = false;
this.oldContent = null;
renderGFM(this.$refs.noteBody.$el);
- this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
- formUpdateHandler({ noteText, callback, resolveDiscussion }) {
+ async formUpdateHandler({ noteText, callback, resolveDiscussion }) {
const position = {
...this.note.position,
};
@@ -320,6 +320,14 @@ export default {
if (this.isDraft) return;
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const data = {
endpoint: this.note.path,
note: {
@@ -383,7 +391,6 @@ export default {
});
if (!confirmed) return;
}
- this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
// eslint-disable-next-line vue/no-mutating-props
this.note.note_html = this.oldContent;
diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue
index 9c3b2139a5d..95c02884ace 100644
--- a/app/assets/javascripts/notes/components/notes_activity_header.vue
+++ b/app/assets/javascripts/notes/components/notes_activity_header.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div
- class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5"
+ class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-pb-3"
>
<h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2>
<div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0">
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index b884c6b6d19..cf7207d260d 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import NotesApp from './components/notes_app.vue';
@@ -58,7 +59,8 @@ export default () => {
provide: {
showTimelineViewToggle,
reportAbusePath: notesDataset.reportAbusePath,
- newSavedRepliesPath: notesDataset.savedRepliesNewPath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id),
},
data() {
return {
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
deleted file mode 100644
index 17272d5abef..00000000000
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { s__ } from '~/locale';
-import Autosave from '~/autosave';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- methods: {
- initAutoSave(noteable, extraKeys = []) {
- let keys = [
- s__('Autosave|Note'),
- capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
- noteable.id,
- ];
-
- if (extraKeys) {
- keys = keys.concat(extraKeys);
- }
-
- this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys);
- },
- resetAutoSave() {
- this.autosave.reset();
- },
- setAutoSave() {
- this.autosave.save();
- },
- disposeAutoSave() {
- this.autosave.dispose();
- },
- },
-};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 3dbcf28d11c..90de7db8c1b 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -63,6 +63,7 @@ function getPreviousDiscussion() {
function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
const discussion = getDiscussion();
+
if (!isOverviewPage() && !discussion) {
window.mrTabs?.eventHub.$once('NotesAppReady', () => {
handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions);
@@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
window.mrTabs?.tabShown('show', undefined, false);
return;
}
- const id = discussion.dataset.discussionId;
- ctx.expandDiscussion({ discussionId: id });
- scrollToElement(discussion, scrollOptions);
+
+ if (discussion) {
+ const id = discussion.dataset.discussionId;
+ ctx.expandDiscussion({ discussionId: id });
+ scrollToElement(discussion, scrollOptions);
+ }
}
export default {
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index 14e97fcef46..9a1323cdaf2 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { marked } from 'marked';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
@@ -8,9 +7,9 @@ import { markdownConfig } from '~/lib/utils/text_utility';
* @param {Boolean} enabled that will be send as a property for the event
*/
export const trackToggleTimelineView = (enabled) => ({
- category: 'Incident Management',
+ category: 'Incident Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'toggle_incident_comments_into_timeline_view',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
property: enabled,
});
diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
new file mode 100644
index 00000000000..c4a928c5e07
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlButton, GlModal } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+import {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ RENEW_SECRET_FAILURE,
+ RENEW_SECRET_SUCCESS,
+ WARNING_NO_SECRET,
+} from '../constants';
+
+export default {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ name: 'OAuthSecret',
+ components: {
+ GlButton,
+ GlModal,
+ InputCopyToggleVisibility,
+ },
+ inject: ['initialSecret', 'renewPath'],
+ data() {
+ return {
+ secret: this.initialSecret,
+ alert: null,
+ isModalVisible: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ text: this.$options.RENEW_SECRET,
+ attributes: {
+ variant: 'confirm',
+ loading: this.isLoading,
+ },
+ };
+ },
+ },
+ created() {
+ if (!this.secret) {
+ this.alert = createAlert({ message: WARNING_NO_SECRET, variant: VARIANT_WARNING });
+ }
+ },
+ methods: {
+ displayModal() {
+ this.isModalVisible = true;
+ },
+ async renewSecret(event) {
+ event.preventDefault();
+ this.isLoading = true;
+ this.alert?.dismiss();
+
+ try {
+ const { data } = await axios.put(this.renewPath);
+ this.alert = createAlert({ message: RENEW_SECRET_SUCCESS, variant: VARIANT_SUCCESS });
+ this.secret = data.secret;
+ } catch {
+ this.alert = createAlert({ message: RENEW_SECRET_FAILURE });
+ } finally {
+ this.isLoading = false;
+ this.isModalVisible = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <input-copy-toggle-visibility
+ v-if="secret"
+ :copy-button-title="$options.COPY_SECRET"
+ :value="secret"
+ class="gl-mt-n3 gl-mb-0"
+ >
+ <template #description>
+ {{ $options.DESCRIPTION_SECRET }}
+ </template>
+ </input-copy-toggle-visibility>
+
+ <gl-button category="secondary" class="gl-align-self-start" @click="displayModal">{{
+ $options.RENEW_SECRET
+ }}</gl-button>
+
+ <gl-modal
+ v-model="isModalVisible"
+ :title="$options.CONFIRM_MODAL_TITLE"
+ size="sm"
+ modal-id="modal-renew-secret"
+ :action-primary="actionPrimary"
+ @primary="renewSecret"
+ >
+ {{ $options.CONFIRM_MODAL }}
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/oauth_application/constants.js b/app/assets/javascripts/oauth_application/constants.js
new file mode 100644
index 00000000000..5eaacadda78
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/constants.js
@@ -0,0 +1,20 @@
+import { __, s__ } from '~/locale';
+
+export const CONFIRM_MODAL = s__(
+ 'AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab.',
+);
+export const CONFIRM_MODAL_TITLE = s__('AuthorizedApplication|Renew secret?');
+export const COPY_SECRET = __('Copy secret');
+export const DESCRIPTION_SECRET = __(
+ 'This is the only time the secret is accessible. Copy the secret and store it securely.',
+);
+export const RENEW_SECRET = s__('AuthorizedApplication|Renew secret');
+export const RENEW_SECRET_FAILURE = s__(
+ 'AuthorizedApplication|There was an error trying to renew the application secret. Please try again.',
+);
+export const RENEW_SECRET_SUCCESS = s__(
+ 'AuthorizedApplication|Application secret was successfully renewed.',
+);
+export const WARNING_NO_SECRET = __(
+ 'The secret is only available when you create the application or renew the secret.',
+);
diff --git a/app/assets/javascripts/oauth_application/index.js b/app/assets/javascripts/oauth_application/index.js
new file mode 100644
index 00000000000..f8f1f647a15
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import OAuthSecret from './components/oauth_secret.vue';
+
+export const initOAuthApplicationSecret = () => {
+ const el = document.querySelector('#js-oauth-application-secret');
+
+ if (!el) {
+ return null;
+ }
+
+ const { initialSecret, renewPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'OAuthSecretRoot',
+ provide: { initialSecret, renewPath },
+ render(h) {
+ return h(OAuthSecret);
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index 5d77ff9dc0d..4e154870f55 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -4,9 +4,10 @@ import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
- UPDATED_AT,
+ CREATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
CLEANUP_ONGOING_TEXT,
@@ -65,11 +66,11 @@ export default {
visibilityIcon() {
return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
},
- timeAgo() {
- return this.timeFormatted(this.imageDetails.updatedAt);
+ formattedCreatedAtDate() {
+ return formatDate(this.imageDetails.createdAt, 'mmm d, yyyy HH:MM', true);
},
- updatedText() {
- return sprintf(UPDATED_AT, { time: this.timeAgo });
+ createdText() {
+ return sprintf(CREATED_AT, { time: this.formattedCreatedAtDate });
},
tagCountText() {
if (this.$apollo.queries.containerRepository.loading) {
@@ -145,9 +146,9 @@ export default {
<template #metadata-updated>
<metadata-item
:icon="visibilityIcon"
- :text="updatedText"
+ :text="createdText"
size="xl"
- data-testid="updated-and-visibility"
+ data-testid="created-and-visibility"
/>
</template>
<template #right-actions>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index 863d1c2629b..a1c837a4add 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -3,12 +3,16 @@ import { GlEmptyState } from '@gitlab/ui';
import { createAlert } from '~/alert';
import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
-
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
+ ALERT_SUCCESS_TAG,
+ ALERT_DANGER_TAG,
+ ALERT_SUCCESS_TAGS,
+ ALERT_DANGER_TAGS,
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE,
@@ -20,19 +24,22 @@ import {
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import TagsListRow from './tags_list_row.vue';
+import DeleteModal from './delete_modal.vue';
export default {
name: 'TagsList',
components: {
+ DeleteModal,
GlEmptyState,
TagsListRow,
TagsLoader,
RegistryList,
PersistedSearch,
},
+ mixins: [Tracking.mixin()],
inject: ['config'],
-
props: {
id: {
type: [Number, String],
@@ -77,6 +84,8 @@ export default {
return {
containerRepository: {},
filters: {},
+ itemsToBeDeleted: [],
+ mutationLoading: false,
sort: null,
};
},
@@ -88,7 +97,7 @@ export default {
return this.containerRepository?.tags?.nodes || [];
},
hideBulkDelete() {
- return !(this.containerRepository?.canDelete || false);
+ return !this.containerRepository?.canDelete;
},
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
@@ -105,7 +114,12 @@ export default {
return this.tags.length === 0;
},
isLoading() {
- return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort;
+ return (
+ this.isImageLoading ||
+ this.$apollo.queries.containerRepository.loading ||
+ this.mutationLoading ||
+ !this.sort
+ );
},
hasFilters() {
return this.filters?.name;
@@ -116,17 +130,61 @@ export default {
emptyStateDescription() {
return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE;
},
+ tracking() {
+ return {
+ label:
+ this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
},
methods: {
+ deleteTags(toBeDeleted) {
+ this.itemsToBeDeleted = toBeDeleted;
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ confirmDelete() {
+ this.handleDeleteTag();
+ },
+ async handleDeleteTag() {
+ this.track('confirm_delete');
+ const { itemsToBeDeleted } = this;
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteContainerRepositoryTagsMutation,
+ variables: {
+ id: this.queryVariables.id,
+ tagNames: itemsToBeDeleted.map((item) => item.name),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getContainerRepositoryTagsQuery,
+ variables: this.queryVariables,
+ },
+ ],
+ });
+ if (data?.destroyContainerRepositoryTags?.errors[0]) {
+ throw new Error();
+ }
+ this.$emit(
+ 'delete',
+ itemsToBeDeleted.length === 1 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS,
+ );
+ this.itemsToBeDeleted = [];
+ } catch (e) {
+ this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS);
+ } finally {
+ this.mutationLoading = false;
+ }
+ },
fetchNextPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
fetchPreviousPage() {
@@ -136,9 +194,6 @@ export default {
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
handleSearchUpdate({ sort, filters }) {
@@ -193,7 +248,7 @@ export default {
id-property="name"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
- @delete="$emit('delete', $event)"
+ @delete="deleteTags"
>
<template #default="{ selectItem, isSelected, item, first }">
<tags-list-row
@@ -203,10 +258,17 @@ export default {
:is-mobile="isMobile"
:disabled="disabled"
@select="selectItem(item)"
- @delete="$emit('delete', [item])"
+ @delete="deleteTags([item])"
/>
</template>
</registry-list>
+
+ <delete-modal
+ ref="deleteModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirmDelete="confirmDelete"
+ @cancel="track('cancel_delete')"
+ />
</template>
</template>
</div>
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 7bb69363743..7ac803a8ece 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
@@ -65,7 +65,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
-export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
+export const CREATED_AT = s__('ContainerRegistry|Created %{time}');
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
index 9d0ecfd2dcb..71538ea5a07 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
@@ -1,14 +1,10 @@
import { s__ } from '~/locale';
-export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
- 'ContainerRegistry|Expiration policy will run in %{time}',
-);
-export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
- 'ContainerRegistry|Expiration policy is disabled.',
-);
+export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}');
+export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.');
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
- 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
+ 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}',
);
export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__(
'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}',
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
index 850dca07a3f..f9820df4a12 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
@@ -6,7 +6,6 @@ Vue.use(VueApollo);
export const mergeVariables = (existing, incoming) => {
if (!incoming) return existing;
- if (!existing) return incoming;
return incoming;
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index e2036d9e63d..eae663acb48 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -7,7 +7,6 @@ query getContainerRepositoryDetails($id: ContainerRepositoryID!) {
location
canDelete
createdAt
- updatedAt
expirationPolicyStartedAt
expirationPolicyCleanupStatus
project {
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index 2b5fb1a70ed..c6ed4c06577 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -15,22 +15,14 @@ import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
import {
- ALERT_SUCCESS_TAG,
- ALERT_DANGER_TAG,
- ALERT_SUCCESS_TAGS,
- ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../constants/index';
-import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
-import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
-import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
export default {
name: 'RegistryDetailsPage',
@@ -76,7 +68,6 @@ export default {
mutationLoading: false,
deleteAlertType: null,
hidePartialCleanupWarning: false,
- deleteImageAlert: false,
};
},
computed: {
@@ -97,8 +88,7 @@ export default {
},
tracking() {
return {
- label:
- this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ label: 'registry_image_delete',
};
},
pageActionsAreDisabled() {
@@ -112,57 +102,8 @@ export default {
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
- deleteTags(toBeDeleted) {
- this.deleteImageAlert = false;
- this.itemsToBeDeleted = toBeDeleted;
- this.track('click_button');
- this.$refs.deleteModal.show();
- },
confirmDelete() {
- if (this.deleteImageAlert) {
- this.$refs.deleteImage.doDelete();
- } else {
- this.handleDeleteTag();
- }
- },
- async handleDeleteTag() {
- this.track('confirm_delete');
- const { itemsToBeDeleted } = this;
- this.itemsToBeDeleted = [];
- this.mutationLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: deleteContainerRepositoryTagsMutation,
- variables: {
- id: this.queryVariables.id,
- tagNames: itemsToBeDeleted.map((i) => i.name),
- },
- awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: getContainerRepositoryTagsQuery,
- variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
- },
- {
- query: getContainerRepositoriesDetails,
- variables: {
- fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
- isGroupPage: this.config.isGroupPage,
- },
- },
- ],
- });
-
- if (data?.destroyContainerRepositoryTags?.errors[0]) {
- throw new Error();
- }
- this.deleteAlertType =
- itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
- } catch (e) {
- this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
- }
-
- this.mutationLoading = false;
+ this.$refs.deleteImage.doDelete();
},
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
@@ -174,7 +115,6 @@ export default {
});
},
deleteImage() {
- this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ ...this.containerRepository }];
this.$refs.deleteModal.show();
},
@@ -185,6 +125,9 @@ export default {
this.itemsToBeDeleted = [];
this.mutationLoading = true;
},
+ showAlert(alertType) {
+ this.deleteAlertType = alertType;
+ },
},
};
</script>
@@ -222,7 +165,7 @@ export default {
:is-image-loading="isLoading"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
- @delete="deleteTags"
+ @delete="showAlert"
/>
<delete-image
@@ -237,7 +180,7 @@ export default {
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
- :delete-image="deleteImageAlert"
+ delete-image
@confirmDelete="confirmDelete"
@cancel="track('cancel_delete')"
/>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index b24ec65464f..d32e90f3adb 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -8,7 +8,6 @@ import {
GlFormInputGroup,
GlModal,
GlModalDirective,
- GlSkeletonLoader,
GlSprintf,
} from '@gitlab/ui';
import { __, s__, n__, sprintf } from '~/locale';
@@ -30,7 +29,6 @@ export default {
GlFormGroup,
GlFormInputGroup,
GlModal,
- GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
@@ -208,23 +206,20 @@ export default {
</template>
</gl-form-group>
- <gl-skeleton-loader v-if="$apollo.queries.group.loading" />
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :loading="$apollo.queries.group.loading"
+ :manifests="manifests"
+ :pagination="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
- <div v-else data-testid="main-area">
- <manifests-list
- v-if="manifests && manifests.length"
- :manifests="manifests"
- :pagination="pageInfo"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
- />
-
- <gl-empty-state
- v-else
- :svg-path="noManifestsIllustration"
- :title="$options.i18n.noManifestTitle"
- />
- </div>
+ <gl-empty-state
+ v-else
+ :svg-path="noManifestsIllustration"
+ :title="$options.i18n.noManifestTitle"
+ />
<gl-modal
:modal-id="$options.confirmClearCacheModal"
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
index 005c8feea3a..0d9b8330fe3 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlKeysetPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
@@ -8,6 +8,7 @@ export default {
components: {
ManifestRow,
GlKeysetPagination,
+ GlSkeletonLoader,
},
props: {
manifests: {
@@ -19,6 +20,11 @@ export default {
type: Object,
required: true,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: () => false,
+ },
},
i18n: {
listTitle: s__('DependencyProxy|Image list'),
@@ -34,19 +40,22 @@ export default {
<template>
<div class="gl-mt-6">
<h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
- <div
- class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
- >
- <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
- </div>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- v-bind="pagination"
- class="gl-mt-3"
- @prev="$emit('prev-page')"
- @next="$emit('next-page')"
- />
+ <gl-skeleton-loader v-if="loading" />
+ <div v-else data-testid="main-area">
+ <div
+ class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
+ >
+ <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
+ </div>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pagination"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 9bab08b8548..a9d076afb92 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
@@ -36,7 +36,7 @@ export default {
},
},
i18n: {
- LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+ LIST_TITLE_TEXT: s__('InfrastructureRegistry|Terraform Module Registry'),
LIST_INTRO_TEXT: s__(
'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
index b167fff26b0..f790c7b1430 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
@@ -1,39 +1,74 @@
<script>
-import { GlModal } from '@gitlab/ui';
-import { __, n__ } from '~/locale';
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
+ DELETE_MODAL_CONTENT,
+ DELETE_MODAL_TITLE,
+ DELETE_PACKAGES_MODAL_DESCRIPTION,
DELETE_PACKAGES_MODAL_TITLE,
DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'DeleteModal',
i18n: {
- DELETE_PACKAGES_MODAL_TITLE,
+ DELETE_MODAL_CONTENT,
+ DELETE_PACKAGES_MODAL_DESCRIPTION,
},
components: {
+ GlLink,
GlModal,
+ GlSprintf,
},
props: {
itemsToBeDeleted: {
type: Array,
required: true,
},
+ showRequestForwardingContent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- description() {
- return n__(
- 'PackageRegistry|You are about to delete 1 package. This operation is irreversible.',
- `PackageRegistry|You are about to delete %d packages. This operation is irreversible.`,
- this.itemsToBeDeleted.length,
- );
+ itemToBeDeleted() {
+ if (this.itemsToBeDeleted.length === 1) {
+ const [itemToBeDeleted] = this.itemsToBeDeleted;
+ return itemToBeDeleted;
+ }
+ return null;
+ },
+ title() {
+ return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE;
+ },
+ packagesDeletePrimaryActionProps() {
+ let text = DELETE_PACKAGE_MODAL_PRIMARY_ACTION;
+
+ if (this.showRequestForwardingContent) {
+ if (this.itemToBeDeleted) {
+ text = DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ } else {
+ text = DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ }
+ }
+ return {
+ text,
+ attributes: { variant: 'danger', category: 'primary' },
+ };
+ },
+ requestForwardingContentMessage() {
+ return this.itemToBeDeleted
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT;
},
},
modal: {
- packagesDeletePrimaryAction: {
- text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
- attributes: { variant: 'danger', category: 'primary' },
- },
cancelAction: {
text: __('Cancel'),
},
@@ -43,6 +78,9 @@ export default {
this.$refs.deleteModal.show();
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -51,12 +89,34 @@ export default {
ref="deleteModal"
size="sm"
modal-id="delete-packages-modal"
- :action-primary="$options.modal.packagesDeletePrimaryAction"
+ :action-primary="packagesDeletePrimaryActionProps"
:action-cancel="$options.modal.cancelAction"
- :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE"
+ :title="title"
@primary="$emit('confirm')"
@cancel="$emit('cancel')"
>
- <span>{{ description }}</span>
+ <p v-if="showRequestForwardingContent">
+ <gl-sprintf :message="requestForwardingContentMessage">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p v-else>
+ <gl-sprintf v-if="itemToBeDeleted" :message="$options.i18n.DELETE_MODAL_CONTENT">
+ <template #version>
+ <strong>{{ itemToBeDeleted.version }}</strong>
+ </template>
+
+ <template #name>
+ <strong>{{ itemToBeDeleted.name }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.i18n.DELETE_PACKAGES_MODAL_DESCRIPTION">
+ <template #count>
+ {{ itemsToBeDeleted.length }}
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index 3d5ac528920..7ea19df7a6c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -1,4 +1,6 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { n__ } from '~/locale';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
@@ -10,16 +12,20 @@ import {
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import Tracking from '~/tracking';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
export default {
components: {
DeleteModal,
DeletePackageModal,
+ GlAlert,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -31,33 +37,65 @@ export default {
required: false,
default: false,
},
- versions: {
- type: Array,
- required: true,
- default: () => [],
- },
- pageInfo: {
- type: Object,
- required: true,
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
},
- isLoading: {
+ isMutationLoading: {
type: Boolean,
required: false,
default: false,
},
+ packageId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
itemToBeDeleted: null,
itemsToBeDeleted: [],
+ packageVersions: {},
+ fetchPackageVersionsError: false,
};
},
+ apollo: {
+ packageVersions: {
+ query: getPackageVersionsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ skip() {
+ return this.isListEmpty;
+ },
+ update(data) {
+ return data.package?.versions ?? {};
+ },
+ error(error) {
+ this.fetchPackageVersionsError = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
+ isListEmpty() {
+ return this.count === 0;
+ },
+ isLoading() {
+ return this.$apollo.queries.packageVersions.loading || this.isMutationLoading;
+ },
+ pageInfo() {
+ return this.packageVersions?.pageInfo ?? {};
+ },
listTitle() {
return n__('%d version', '%d versions', this.versions.length);
},
- isListEmpty() {
- return this.versions.length === 0;
+ queryVariables() {
+ return {
+ id: this.packageId,
+ first: GRAPHQL_PAGE_SIZE,
+ };
},
tracking() {
const category = this.itemToBeDeleted
@@ -67,6 +105,9 @@ export default {
category,
};
},
+ versions() {
+ return this.packageVersions?.nodes ?? [];
+ },
},
methods: {
deleteItemConfirmation() {
@@ -101,6 +142,32 @@ export default {
this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
},
+ fetchPreviousVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ };
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ fetchNextVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo?.endCursor,
+ };
+
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ },
+ i18n: {
+ errorMessage: FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
},
};
</script>
@@ -109,6 +176,9 @@ export default {
<div v-if="isLoading">
<packages-list-loader />
</div>
+ <gl-alert v-else-if="fetchPackageVersionsError" variant="danger" :dismissible="false">{{
+ $options.i18n.errorMessage
+ }}</gl-alert>
<slot v-else-if="isListEmpty" name="empty-state"></slot>
<div v-else>
<registry-list
@@ -118,8 +188,8 @@ export default {
:pagination="pageInfo"
:title="listTitle"
@delete="setItemsToBeDeleted"
- @prev-page="$emit('prev-page')"
- @next-page="$emit('next-page')"
+ @prev-page="fetchPreviousVersionsPage"
+ @next-page="fetchNextVersionsPage"
>
<template #default="{ first, item, isSelected, selectItem }">
<version-row
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 193a222853f..37a6fe75f15 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -130,13 +130,14 @@ export default {
<template v-if="packageEntity.canDestroy" #right-action>
<gl-dropdown
+ data-testid="delete-dropdown"
icon="ellipsis_v"
:text="$options.i18n.moreActions"
:text-sr-only="true"
category="tertiary"
no-caret
>
- <gl-dropdown-item variant="danger" @click="$emit('delete')">{{
+ <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
$options.i18n.deletePackage
}}</gl-dropdown-item>
</gl-dropdown>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
index 440e11a99f2..05359128af4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -39,5 +39,8 @@ export default {
<template #metadata-amount>
<metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
</template>
+ <template #right-actions>
+ <slot name="settings-link"></slot>
+ </template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 486ab4fdc99..effed4891d8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,7 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
@@ -14,16 +13,24 @@ import {
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
PACKAGE_ERROR_STATUS,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
+const forwardingFieldToPackageTypeMapping = {
+ mavenPackageRequestsForwarding: PACKAGE_TYPE_MAVEN,
+ npmPackageRequestsForwarding: PACKAGE_TYPE_NPM,
+ pypiPackageRequestsForwarding: PACKAGE_TYPE_PYPI,
+};
+
export default {
name: 'PackagesList',
components: {
GlAlert,
DeleteModal,
- DeletePackageModal,
PackagesListLoader,
PackagesListRow,
RegistryList,
@@ -44,16 +51,27 @@ export default {
type: Object,
required: true,
},
+ groupSettings: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
- itemToBeDeleted: null,
itemsToBeDeleted: [],
errorPackages: [],
};
},
computed: {
+ itemToBeDeleted() {
+ if (this.itemsToBeDeleted.length === 1) {
+ const [itemToBeDeleted] = this.itemsToBeDeleted;
+ return itemToBeDeleted;
+ }
+ return null;
+ },
listTitle() {
return n__('%d package', '%d packages', this.list.length);
},
@@ -77,6 +95,15 @@ export default {
showErrorPackageAlert() {
return this.errorPackages.length > 0;
},
+ packageTypesWithForwardingEnabled() {
+ return Object.keys(this.groupSettings)
+ .filter((field) => this.groupSettings[field])
+ .map((field) => forwardingFieldToPackageTypeMapping[field]);
+ },
+ isRequestForwardingEnabled() {
+ const selectedPackageTypes = new Set(this.itemsToBeDeleted.map((item) => item.packageType));
+ return this.packageTypesWithForwardingEnabled.some((type) => selectedPackageTypes.has(type));
+ },
},
watch: {
list(newVal) {
@@ -88,40 +115,36 @@ export default {
this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : [];
},
methods: {
- setItemToBeDeleted(item) {
- this.itemToBeDeleted = { ...item };
- this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
- },
setItemsToBeDeleted(items) {
+ this.itemsToBeDeleted = items;
if (items.length === 1) {
- const [item] = items;
- this.setItemToBeDeleted(item);
- return;
+ this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
}
- this.itemsToBeDeleted = items;
- this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
},
deleteItemsConfirmation() {
this.$emit('delete', this.itemsToBeDeleted);
- this.track(DELETE_PACKAGES_TRACKING_ACTION);
+
+ if (this.itemToBeDeleted) {
+ this.track(DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGES_TRACKING_ACTION);
+ }
+
this.itemsToBeDeleted = [];
},
deleteItemsCanceled() {
- this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ }
this.itemsToBeDeleted = [];
},
- deleteItemConfirmation() {
- this.$emit('delete', [this.itemToBeDeleted]);
- this.track(DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
- deleteItemCanceled() {
- this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
showConfirmationModal() {
- this.setItemToBeDeleted(this.errorPackages[0]);
+ this.setItemsToBeDeleted([this.errorPackages[0]]);
},
},
i18n: {
@@ -165,21 +188,16 @@ export default {
:first="first"
:package-entity="item"
:selected="isSelected(item)"
- @delete="setItemToBeDeleted(item)"
+ @delete="setItemsToBeDeleted([item])"
@select="selectItem(item)"
/>
</template>
</registry-list>
- <delete-package-modal
- :item-to-be-deleted="itemToBeDeleted"
- @ok="deleteItemConfirmation"
- @cancel="deleteItemCanceled"
- />
-
<delete-modal
ref="deletePackagesModal"
:items-to-be-deleted="itemsToBeDeleted"
+ :show-request-forwarding-content="isRequestForwardingEnabled"
@confirm="deleteItemsConfirmation"
@cancel="deleteItemsCanceled"
/>
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 eda8d9e0066..ad5edcd7602 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -115,6 +115,10 @@ export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version';
export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version';
export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version';
+export const FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Failed to load version data',
+);
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -122,6 +126,21 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del
export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages');
export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
+export const DELETE_PACKAGES_MODAL_DESCRIPTION = s__(
+ 'PackageRegistry|You are about to delete %{count} packages. This operation is irreversible.',
+);
+export const DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete package',
+);
+export const DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete selected packages',
+);
+export const DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete the package anyway? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
+export const DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Some of the selected package formats allow request forwarding. Deleting a package while request forwarding is enabled for the project can pose a security risk. Do you want to proceed with deleting the selected packages? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
@@ -207,5 +226,9 @@ export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/inde
export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index');
export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index');
export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
new file mode 100644
index 00000000000..db05f497b7f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
@@ -0,0 +1,8 @@
+fragment GroupPackageSettings on Group {
+ id
+ packageSettings {
+ mavenPackageRequestsForwarding
+ npmPackageRequestsForwarding
+ pypiPackageRequestsForwarding
+ }
+}
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 56f95fa2c1f..39e5da54509 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
@@ -4,6 +4,27 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
+export const mergeVariables = (existing, incoming) => {
+ if (!incoming) return existing;
+ return incoming;
+};
+
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ PackageDetailsType: {
+ fields: {
+ versions: {
+ keyArgs: false,
+ 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 b5313f929f8..99864f7ad0c 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
@@ -1,10 +1,4 @@
-query getPackageDetails(
- $id: PackagesPackageID!
- $first: Int
- $last: Int
- $after: String
- $before: String
-) {
+query getPackageDetails($id: PackagesPackageID!) {
package(id: $id) {
id
name
@@ -62,31 +56,8 @@ query getPackageDetails(
downloadPath
}
}
- versions(after: $after, before: $before, first: $first, last: $last) {
+ versions {
count
- nodes {
- id
- name
- canDestroy
- createdAt
- version
- status
- _links {
- webPath
- }
- tags(first: 1) {
- nodes {
- id
- name
- }
- }
- }
- pageInfo {
- hasNextPage
- hasPreviousPage
- endCursor
- startCursor
- }
}
dependencyLinks {
nodes {
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
new file mode 100644
index 00000000000..a4119ac5821
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
@@ -0,0 +1,38 @@
+query getPackageVersions(
+ $id: PackagesPackageID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ package(id: $id) {
+ id
+ versions(after: $after, before: $before, first: $first, last: $last) {
+ count
+ nodes {
+ id
+ name
+ canDestroy
+ createdAt
+ packageType
+ version
+ status
+ _links {
+ webPath
+ }
+ tags(first: 1) {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
index 5bde5f08e56..f25f24cbc5f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -1,4 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getPackages(
@@ -32,6 +33,9 @@ query getPackages(
...PageInfo
}
}
+ group {
+ ...GroupPackageSettings
+ }
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
id
@@ -52,5 +56,6 @@ query getPackages(
...PageInfo
}
}
+ ...GroupPackageSettings
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
index 15ed98122a0..e2f8d239bae 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -19,6 +19,7 @@ export default () => {
npmInstanceUrl,
projectListUrl,
groupListUrl,
+ settingsPath,
} = el.dataset;
const isGroupPage = pageType === 'groups';
@@ -48,6 +49,7 @@ export default () => {
projectListUrl,
groupListUrl,
breadCrumbState,
+ settingsPath,
},
render(createElement) {
return createElement(PackageRegistry);
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 1ce2140894e..0f1c63a04ad 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
@@ -54,6 +54,7 @@ import {
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';
export default {
@@ -135,7 +136,6 @@ export default {
queryVariables() {
return {
id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
- first: GRAPHQL_PAGE_SIZE,
};
},
packageFiles() {
@@ -147,9 +147,6 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
- isVersionsLoading() {
- return this.isLoading || this.versionsMutationLoading;
- },
packageFilesLoading() {
return this.isLoading || this.mutationLoading;
},
@@ -161,12 +158,12 @@ export default {
category: packageTypeToTrackCategory(this.packageType),
};
},
- versionPageInfo() {
- return this.packageEntity?.versions?.pageInfo ?? {};
- },
packageDependencies() {
return this.packageEntity.dependencyLinks?.nodes || [];
},
+ packageVersionsCount() {
+ return this.packageEntity.versions?.count ?? 0;
+ },
showDependencies() {
return this.packageType === PACKAGE_TYPE_NUGET;
},
@@ -190,6 +187,17 @@ export default {
},
];
},
+ refetchVersionsQueryData() {
+ return [
+ {
+ query: getPackageVersionsQuery,
+ variables: {
+ id: this.queryVariables.id,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ },
+ ];
+ },
},
methods: {
formatSize(size) {
@@ -274,34 +282,6 @@ export default {
resetDeleteModalContent() {
this.deletePackageModalContent = DELETE_MODAL_CONTENT;
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
- fetchPreviousVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: null,
- last: GRAPHQL_PAGE_SIZE,
- before: this.versionPageInfo?.startCursor,
- };
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
- fetchNextVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: GRAPHQL_PAGE_SIZE,
- last: null,
- after: this.versionPageInfo?.endCursor,
- };
-
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
},
i18n: {
DELETE_MODAL_TITLE,
@@ -403,12 +383,12 @@ export default {
<template #title>
<span>{{ $options.i18n.otherVersionsTabTitle }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{
- packageEntity.versions.count
+ packageVersionsCount
}}</gl-badge>
</template>
<delete-packages
- :refetch-queries="refetchQueriesData"
+ :refetch-queries="refetchVersionsQueryData"
show-success-alert
@start="versionsMutationLoading = true"
@end="versionsMutationLoading = false"
@@ -416,12 +396,10 @@ export default {
<template #default="{ deletePackages }">
<package-versions-list
:can-destroy="packageEntity.canDestroy"
- :is-loading="isVersionsLoading"
- :page-info="versionPageInfo"
- :versions="packageEntity.versions.nodes"
+ :count="packageVersionsCount"
+ :is-mutation-loading="versionsMutationLoading"
+ :package-id="packageEntity.id"
@delete="deletePackages"
- @prev-page="fetchPreviousVersionsPage"
- @next-page="fetchNextVersionsPage"
>
<template #empty-state>
<p class="gl-mt-3" data-testid="no-versions-message">
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 6e92a6420ac..044ce4e6413 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
@@ -1,5 +1,5 @@
<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_INFO } from '~/alert';
import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { historyReplaceState } from '~/lib/utils/common_utils';
@@ -19,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
GlSprintf,
@@ -27,23 +28,26 @@ export default {
PackageSearch,
DeletePackages,
},
- inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['emptyListIllustration', 'isGroupPage', 'fullPath', 'settingsPath'],
data() {
return {
- packages: {},
+ packagesResource: {},
sort: '',
filters: {},
mutationLoading: false,
};
},
apollo: {
- packages: {
+ packagesResource: {
query: getPackagesQuery,
variables() {
return this.queryVariables;
},
update(data) {
- return data[this.graphqlResource]?.packages ?? {};
+ return data[this.graphqlResource] ?? {};
},
skip() {
return !this.sort;
@@ -51,6 +55,14 @@ export default {
},
},
computed: {
+ packages() {
+ return this.packagesResource?.packages ?? {};
+ },
+ groupSettings() {
+ return this.isGroupPage
+ ? this.packagesResource?.packageSettings ?? {}
+ : this.packagesResource?.group?.packageSettings ?? {};
+ },
queryVariables() {
return {
isGroupPage: this.isGroupPage,
@@ -83,7 +95,7 @@ export default {
: this.$options.i18n.noResultsTitle;
},
isLoading() {
- return this.$apollo.queries.packages.loading || this.mutationLoading;
+ return this.$apollo.queries.packagesResource.loading || this.mutationLoading;
},
refetchQueriesData() {
return [
@@ -123,7 +135,7 @@ export default {
after: this.pageInfo?.endCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -136,7 +148,7 @@ export default {
before: this.pageInfo?.startCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -149,6 +161,7 @@ export default {
noResultsText: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
+ settingsText: s__('PackageRegistry|Configure in settings'),
},
links: {
EMPTY_LIST_HELP_URL,
@@ -159,7 +172,16 @@ export default {
<template>
<div>
- <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
+ <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount">
+ <template v-if="settingsPath" #settings-link>
+ <gl-button
+ v-gl-tooltip="$options.i18n.settingsText"
+ icon="settings"
+ :href="settingsPath"
+ :aria-label="$options.i18n.settingsText"
+ />
+ </template>
+ </package-title>
<package-search class="gl-mb-5" @update="handleSearchUpdate" />
<delete-packages
@@ -170,6 +192,7 @@ export default {
>
<template #default="{ deletePackages }">
<package-list
+ :group-settings="groupSettings"
:list="packages.nodes"
:is-loading="isLoading"
:page-info="pageInfo"
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
index b7d7f0aaca7..ab88d9e8936 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
@@ -1,12 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { isEqual } from 'lodash';
import {
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
PACKAGE_FORWARDING_FORM_BUTTON,
PACKAGE_FORWARDING_FIELDS,
MAVEN_FORWARDING_FIELDS,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/settings/group/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
@@ -20,12 +22,15 @@ export default {
name: 'PackageForwardingSettings',
i18n: {
PACKAGE_FORWARDING_FORM_BUTTON,
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
},
components: {
ForwardingSettings,
GlButton,
+ GlLink,
+ GlSprintf,
SettingsBlock,
},
mixins: [glFeatureFlagsMixin()],
@@ -150,6 +155,9 @@ export default {
this.$set(this.workingCopy, type, value);
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -157,9 +165,14 @@ export default {
<settings-block>
<template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template>
<template #description>
- <span data-testid="description">
+ <span class="gl-display-block gl-mb-2" data-testid="description">
{{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }}
</span>
+ <gl-sprintf :message="$options.i18n.PACKAGE_FORWARDING_SECURITY_DESCRIPTION">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
<template #default>
<form @submit.prevent="submit">
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index b47759df35f..fa73c01c5c4 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -17,6 +17,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
+export const PACKAGE_FORWARDING_SECURITY_DESCRIPTION = s__(
+ 'PackageRegistry|There are security risks if packages are deleted while request forwarding is enabled. %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding');
export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.',
@@ -79,3 +82,7 @@ export const MAVEN_FORWARDING_FIELDS = {
// Parameters
export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
index 7a9ea7c0bf7..35fc0910a16 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
@@ -8,7 +8,7 @@ import {
export default {
i18n: {
- toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ toggleLabel: s__('ContainerRegistry|Enable cleanup policy'),
},
components: {
GlFormGroup,
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 731fb3e4c45..5f59372e5ba 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -1,6 +1,6 @@
import { s__, __ } from '~/locale';
-export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`);
+export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies');
export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
`ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
index 76623377d90..adffab277cc 100644
--- a/app/assets/javascripts/packages_and_registries/shared/utils.js
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -55,15 +55,6 @@ export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) =>
RegistryBreadcrumb,
},
render(createElement) {
- // FIXME(@tnir): this is a workaround until the MR gets merged:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
- const parentEl = breadCrumbEl.parentElement.parentElement;
- if (parentEl) {
- parentEl.classList.remove('breadcrumbs-container');
- parentEl.classList.add('gl-display-flex');
- parentEl.classList.add('w-100');
- }
- // End of FIXME(@tnir)
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js
index feceeb0b10a..ea7c9042e6d 100644
--- a/app/assets/javascripts/pages/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/abuse_reports/index.js
@@ -1,3 +1,5 @@
import { initLinkToSpam } from '~/abuse_reports';
+import initFilePickers from '~/file_pickers';
initLinkToSpam();
+initFilePickers();
diff --git a/app/assets/javascripts/pages/admin/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js
index 3397b02aeba..df9e38431b0 100644
--- a/app/assets/javascripts/pages/admin/applications/index.js
+++ b/app/assets/javascripts/pages/admin/applications/index.js
@@ -1,3 +1,5 @@
import initApplicationDeleteButtons from '~/admin/applications';
+import { initOAuthApplicationSecret } from '~/oauth_application';
initApplicationDeleteButtons();
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
index 72cfc005782..72cfc005782 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
index 3bc785ee1b6..3bc785ee1b6 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js
index cfde1fc0a2b..84be895e194 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
+++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js
@@ -1,4 +1,5 @@
import { s__, __ } from '~/locale';
+import { DEFAULT_FIELDS } from '~/jobs/components/table/constants';
export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
@@ -10,3 +11,11 @@ export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed');
export const CANCEL_JOBS_WARNING = s__(
"AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?",
);
+
+/* Admin Table constants */
+export const DEFAULT_FIELDS_ADMIN = [
+ ...DEFAULT_FIELDS.slice(0, 2),
+ { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' },
+ { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' },
+ ...DEFAULT_FIELDS.slice(2),
+];
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
new file mode 100644
index 00000000000..b89e311ff1d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
@@ -0,0 +1,118 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { queryToObject } from '~/lib/utils/url_utility';
+import { validateQueryString } from '~/jobs/components/filtered_search/utils';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
+import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue';
+import { DEFAULT_FIELDS_ADMIN } from '../constants';
+import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql';
+
+export default {
+ i18n: {
+ jobsFetchErrorMsg: __('There was an error fetching the jobs.'),
+ },
+ components: {
+ JobsTableEmptyState,
+ GlAlert,
+ JobsTable,
+ JobsTableTabs,
+ },
+ inject: {
+ jobStatuses: {
+ default: null,
+ },
+ url: {
+ default: '',
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetAllJobs,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data || {};
+ return {
+ list,
+ pageInfo,
+ count,
+ };
+ },
+ error() {
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ list: [],
+ },
+ error: '',
+ count: 0,
+ scope: null,
+ infiniteScrollingTriggered: false,
+ filterSearchTriggered: false,
+ DEFAULT_FIELDS_ADMIN,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ // Show when on All tab with no jobs
+ // Show only when not loading and filtered search has not been triggered
+ // So we don't show empty state when results are empty on a filtered search
+ showEmptyState() {
+ return (
+ this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered
+ );
+ },
+ variables() {
+ return { ...this.validatedQueryString };
+ },
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
+ jobsCount() {
+ return this.jobs.count;
+ },
+ },
+ watch: {
+ // this watcher ensures that the count on the all tab
+ // is not updated when switching to the finished tab
+ jobsCount(newCount) {
+ if (this.scope) return;
+
+ this.count = newCount;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''">
+ {{ error }}
+ </gl-alert>
+
+ <jobs-table-tabs :all-jobs-count="count" :loading="loading" />
+
+ <jobs-table-empty-state v-if="showEmptyState" />
+
+ <jobs-table
+ v-else
+ :jobs="jobs.list"
+ :table-fields="DEFAULT_FIELDS_ADMIN"
+ class="gl-table-no-top-border"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
new file mode 100644
index 00000000000..fd7ee2a6f8c
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
@@ -0,0 +1,62 @@
+import { isEqual } from 'lodash';
+
+export default {
+ typePolicies: {
+ Query: {
+ fields: {
+ jobs: {
+ keyArgs: ['statuses'],
+ },
+ },
+ },
+ CiJobConnection: {
+ merge(existing = {}, incoming, { args = {} }) {
+ if (incoming.nodes) {
+ let nodes;
+
+ const areNodesEqual = isEqual(existing.nodes, incoming.nodes);
+ const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses;
+ const { pageInfo } = incoming;
+
+ if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
+ if (areNodesEqual) {
+ if (incoming.pageInfo.hasNextPage) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
+ } else {
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
+ } else {
+ nodes = [...incoming.nodes];
+ }
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ return {
+ nodes: existing.nodes,
+ pageInfo: existing.pageInfo,
+ statuses: args.statuses,
+ };
+ },
+ },
+ },
+};
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
new file mode 100644
index 00000000000..374009efa15
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
@@ -0,0 +1,81 @@
+query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
+ count
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ artifacts {
+ nodes {
+ id
+ downloadPath
+ fileType
+ }
+ }
+ allowFailure
+ status
+ scheduledAt
+ manualJob
+ triggered
+ createdByTag
+ detailedStatus {
+ id
+ detailsPath
+ group
+ icon
+ label
+ text
+ tooltip
+ action {
+ id
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ pipeline {
+ id
+ project {
+ id
+ fullPath
+ webUrl
+ }
+ path
+ user {
+ id
+ webPath
+ avatarUrl
+ }
+ }
+ stage {
+ id
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ stuck
+ userPermissions {
+ readBuild
+ readJobArtifacts
+ updateBuild
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
deleted file mode 100644
index c5a0509b625..00000000000
--- a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-export default {
- inject: {
- jobStatuses: {
- default: null,
- },
- url: {
- default: '',
- },
- emptyStateSvgPath: {
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <div>{{ __('Jobs') }}</div>
-</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 9df52557212..9c2a255a1a3 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,11 +1,21 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import { CANCEL_JOBS_MODAL_ID } from './components/constants';
-import CancelJobsModal from './components/cancel_jobs_modal.vue';
-import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue';
+import createDefaultClient from '~/lib/graphql';
+import { CANCEL_JOBS_MODAL_ID } from '../components/constants';
+import CancelJobsModal from '../components/cancel_jobs_modal.vue';
+import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue';
+import cacheConfig from '../components/table/graphql/cache_config';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+const client = createDefaultClient({}, { cacheConfig });
+
+const apolloProvider = new VueApollo({
+ defaultClient: client,
+});
function initJobs() {
const buttonId = 'js-stop-jobs-button';
@@ -44,6 +54,7 @@ export function initAdminJobsApp() {
return new Vue({
el: containerEl,
+ apolloProvider,
provide: {
url,
emptyStateSvgPath,
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index dec06fe6f4d..721168f6140 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -9,6 +9,7 @@ import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import initConfirmDanger from '~/init_confirm_danger';
+import { initGroupSettingsReadme } from '~/groups/settings/init_group_settings_readme';
initFilePickers();
initConfirmDanger();
@@ -27,3 +28,5 @@ initProjectSelects();
initSearchSettings();
initCascadingSettingsLockPopovers();
+
+initGroupSettingsReadme();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 1b3c7ba5a52..2e71eced66f 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -23,7 +23,7 @@ const APP_OPTIONS = {
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'],
+ tokens: ['two_factor', 'with_inherited_permissions', 'enterprise', 'user_type'],
searchParam: 'search',
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 8b68cb5f3bf..513f4968dbd 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -11,6 +11,10 @@ export default {
NewNamespacePage,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
groupsUrl: {
type: String,
required: true,
@@ -44,6 +48,7 @@ export default {
{ text: s__('GroupsNew|New subgroup'), href: '#' },
]
: [
+ { text: s__('Navigation|Your work'), href: this.rootPath },
{ text: s__('GroupsNew|Groups'), href: this.groupsUrl },
{ text: s__('GroupsNew|New group'), href: '#' },
];
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index b16c5f3da9f..6227d5ff880 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -22,6 +22,7 @@ initFilePickers();
function initNewGroupCreation(el) {
const {
hasErrors,
+ rootPath,
groupsUrl,
parentGroupUrl,
parentGroupName,
@@ -33,6 +34,7 @@ function initNewGroupCreation(el) {
const props = {
groupsUrl,
+ rootPath,
parentGroupUrl,
parentGroupName,
importExistingGroupPath,
diff --git a/app/assets/javascripts/pages/groups/runners/new/index.js b/app/assets/javascripts/pages/groups/runners/new/index.js
new file mode 100644
index 00000000000..318643d95a4
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initGroupNewRunner } from '~/ci/runner/group_new_runner';
+
+initGroupNewRunner();
diff --git a/app/assets/javascripts/pages/groups/runners/register/index.js b/app/assets/javascripts/pages/groups/runners/register/index.js
new file mode 100644
index 00000000000..b02e33e21f2
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initGroupRegisterRunner } from '~/ci/runner/group_register_runner';
+
+initGroupRegisterRunner();
diff --git a/app/assets/javascripts/pages/groups/settings/applications/index.js b/app/assets/javascripts/pages/groups/settings/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 52124865bcc..dba65c7e791 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
@@ -7,11 +5,11 @@ import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
export default function initGroupDetails() {
- new ShortcutsNavigation();
+ new ShortcutsNavigation(); // eslint-disable-line no-new
initNotificationsDropdown();
- new ProjectsList();
+ new ProjectsList(); // eslint-disable-line no-new
initInviteMembersBanner();
initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 53bceb3a6f0..f6a4ca0f360 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,5 +1,6 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
+import { initGroupReadme } from '~/groups/init_group_readme';
import initReadMore from '~/read_more';
import initGroupDetails from '../shared/group_details';
@@ -7,3 +8,4 @@ leaveByUrl('group');
initGroupDetails();
initGroupOverviewTabs();
initReadMore();
+initGroupReadme();
diff --git a/app/assets/javascripts/pages/import/github/details/index.js b/app/assets/javascripts/pages/import/github/details/index.js
new file mode 100644
index 00000000000..44a85589c9d
--- /dev/null
+++ b/app/assets/javascripts/pages/import/github/details/index.js
@@ -0,0 +1,3 @@
+import initImportDetails from '~/import/details';
+
+initImportDetails();
diff --git a/app/assets/javascripts/pages/oauth/applications/index.js b/app/assets/javascripts/pages/oauth/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/oauth/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/profiles/comment_templates/index.js b/app/assets/javascripts/pages/profiles/comment_templates/index.js
new file mode 100644
index 00000000000..413816c29cc
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/comment_templates/index.js
@@ -0,0 +1,3 @@
+import { initCommentTemplates } from '~/comment_templates';
+
+initCommentTemplates();
diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js
deleted file mode 100644
index ef227b82172..00000000000
--- a/app/assets/javascripts/pages/profiles/saved_replies/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initSavedReplies } from '~/saved_replies';
-
-initSavedReplies();
diff --git a/app/assets/javascripts/pages/projects/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js
index 4aa9b225790..df8f110a60d 100644
--- a/app/assets/javascripts/pages/projects/artifacts/index.js
+++ b/app/assets/javascripts/pages/projects/artifacts/index.js
@@ -1,3 +1,3 @@
-import { initArtifactsTable } from '~/artifacts/index';
+import { initArtifactsTable } from '~/ci/artifacts/index';
initArtifactsTable();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 02fcc6ea940..ec894586803 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -8,6 +8,7 @@ import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
+import ForkInfo from '~/repository/components/fork_info.vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
@@ -16,6 +17,7 @@ import createStore from '~/code_navigation/store';
import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
import RefSelector from '~/ref/components/ref_selector.vue';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -44,6 +46,7 @@ const initRefSwitcher = () => {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -58,7 +61,15 @@ const initRefSwitcher = () => {
initRefSwitcher();
if (viewBlobEl) {
- const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
+ const {
+ blobPath,
+ projectPath,
+ targetBranch,
+ originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -69,6 +80,9 @@ if (viewBlobEl) {
provide: {
targetBranch,
originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
},
render(createElement) {
return createElement(BlobContentViewer, {
@@ -87,6 +101,47 @@ if (viewBlobEl) {
initBlob();
}
+const initForkInfo = () => {
+ const forkEl = document.getElementById('js-fork-info');
+ if (!forkEl) {
+ return null;
+ }
+ const {
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ canSyncBranch,
+ aheadComparePath,
+ behindComparePath,
+ canUserCreateMrInFork,
+ createMrPath,
+ } = forkEl.dataset;
+ return new Vue({
+ el: forkEl,
+ apolloProvider,
+ render(h) {
+ return h(ForkInfo, {
+ props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ aheadComparePath,
+ behindComparePath,
+ canUserCreateMrInFork,
+ createMrPath,
+ },
+ });
+ },
+ });
+};
+
+initForkInfo();
+
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link');
if (statusLink) {
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index f871cd804e7..9a47a720709 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -18,11 +18,7 @@ import '~/sourcegraph/load';
import DiffStats from '~/diffs/components/diff_stats.vue';
import { initReportAbuse } from '~/projects/report_abuse';
-const hasPerfBar = document.querySelector('.with-performance-bar');
-const performanceHeight = hasPerfBar ? 35 : 0;
-initDiffStatsDropdown(
- (document.querySelector('.navbar-gitlab')?.offsetHeight ?? 0) + performanceHeight,
-);
+initDiffStatsDropdown(true);
new ZenMode();
new ShortcutsNavigation();
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index 760bf3f7131..5bcdd34e258 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -7,8 +7,7 @@ import syntaxHighlight from '~/syntax_highlight';
initCompareSelector();
new Diff(); // eslint-disable-line no-new
-const paddingTop = 16;
-initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+initDiffStatsDropdown(true);
GpgBadges.fetch();
syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
index 05a1bbc69ed..fe9f0c7e69f 100644
--- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -1,3 +1,3 @@
-import initCycleAnalytics from '~/analytics/cycle_analytics';
+import cycleAnalyticsAppBundle from 'ee_else_ce/analytics/cycle_analytics/bundle';
-initCycleAnalytics();
+cycleAnalyticsAppBundle();
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index 2718765ee23..3d81e77f879 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import MergeRequest from '~/merge_request';
import CompareApp from '~/merge_requests/components/compare_app.vue';
import { __ } from '~/locale';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
if (mrNewCompareNode) {
@@ -82,4 +84,6 @@ if (mrNewCompareNode) {
action: mrNewSubmitNode.dataset.mrSubmitAction,
});
initPipelines();
+ // eslint-disable-next-line no-new
+ new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index f8cb8b30250..6127adc3584 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,10 +1,11 @@
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import initCheckFormState from './check_form_state';
import initFormUpdate from './update_form';
@@ -72,3 +73,5 @@ initMergeRequest();
initFormUpdate();
initCheckFormState();
initTargetBranchSelector();
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index d4734b8842d..599fd225de9 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -4,19 +4,13 @@ import $ from 'jquery';
import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GLForm from '~/gl_form';
import LabelsSelect from '~/labels/labels_select';
-import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
export default () => {
new ShortcutsNavigation();
- new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
IssuableLabelSelector();
new LabelsSelect();
- new IssuableTemplateSelectors({
- warnTemplateOverride: true,
- });
mountMilestoneDropdown('[name="merge_request[milestone_id]"]');
};
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
index a90cabb3c68..f50763151ef 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -9,6 +9,7 @@ const initShowExperiment = () => {
}
const props = {
+ experiment: JSON.parse(element.dataset.experiment),
candidates: JSON.parse(element.dataset.candidates),
metricNames: JSON.parse(element.dataset.metrics),
paramNames: JSON.parse(element.dataset.params),
diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
index 414636f0a74..a669ea5baaf 100644
--- a/app/assets/javascripts/pages/projects/network/show/index.js
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -24,7 +24,7 @@ const initRefSwitcher = () => {
},
on: {
input(selectedRef) {
- visitUrl(joinPaths(networkRootPath, selectedRef));
+ visitUrl(joinPaths(networkRootPath, encodeURIComponent(selectedRef)));
},
},
});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 242c5a1a97b..eab4be4dcf1 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -176,7 +176,7 @@ export default {
<gl-icon
v-if="showDailyLimitMessage(option)"
v-gl-tooltip.hover
- name="question"
+ name="question-o"
:title="scheduleDailyLimitMsg"
/>
</gl-form-radio>
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 5f15a11e708..e6ee6b702bb 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -3,28 +3,10 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import initClonePanel from '~/clone_panel';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { serializeForm } from '~/lib/utils/forms';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-
-const BRANCH_REF_TYPE = 'heads';
-const TAG_REF_TYPE = 'tags';
-const BRANCH_GROUP_NAME = __('Branches');
-const TAG_GROUP_NAME = __('Tags');
export default class Project {
constructor() {
initClonePanel();
- // Ref switcher
- if (document.querySelector('.js-project-refs-dropdown')) {
- Project.initRefSwitcher();
- $('.project-refs-select').on('change', function () {
- return $(this).parents('form').trigger('submit');
- });
- }
$('.js-hide-no-ssh-message').on('click', function (e) {
setCookie('hide_no_ssh_message', 'false');
@@ -48,125 +30,4 @@ export default class Project {
static changeProject(url) {
return (window.location = url);
}
-
- static initRefSwitcher() {
- const refListItem = document.createElement('li');
- const refLink = document.createElement('a');
-
- refLink.href = '#';
-
- return $('.js-project-refs-dropdown').each(function () {
- const $dropdown = $(this);
- const selected = $dropdown.data('selected');
- const refType = $dropdown.data('refType');
- const fieldName = $dropdown.data('fieldName');
- const shouldVisit = Boolean($dropdown.data('visit'));
- const $form = $dropdown.closest('form');
- const path = $form.find('#path').val();
- const action = $form.attr('action');
- const linkTarget = mergeUrlParams(serializeForm($form[0]), action);
-
- return initDeprecatedJQueryDropdown($dropdown, {
- data(term, callback) {
- axios
- .get($dropdown.data('refsUrl'), {
- params: {
- ref: $dropdown.data('ref'),
- search: term,
- },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('An error occurred while getting projects'),
- }),
- );
- },
- selectable: true,
- filterable: true,
- filterRemote: true,
- filterByText: true,
- inputFieldName: $dropdown.data('inputFieldName'),
- fieldName,
- renderRow(ref, _, params) {
- const li = refListItem.cloneNode(false);
-
- const link = refLink.cloneNode(false);
-
- if (ref === selected) {
- // Check group and current ref type to avoid adding a class when tags and branches share the same name
- if (
- (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) ||
- (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) ||
- !refType
- ) {
- link.className = 'is-active';
- }
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
- if (ref.length > 0 && shouldVisit) {
- const urlParams = { [fieldName]: ref };
- if (params.group === BRANCH_GROUP_NAME) {
- urlParams.ref_type = BRANCH_REF_TYPE;
- } else {
- urlParams.ref_type = TAG_REF_TYPE;
- }
- link.href = mergeUrlParams(urlParams, linkTarget);
- }
-
- li.appendChild(link);
-
- return li;
- },
- id(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel(obj, $el) {
- return $el.text().trim();
- },
- clicked(options) {
- const { e } = options;
-
- if (!shouldVisit) {
- e.preventDefault();
- }
-
- // Some pages need to dynamically get the current path
- // so they can opt-in to JS getting the path from the
- // current URL by not setting a path in the dropdown form
- if (shouldVisit && path === undefined) {
- e.preventDefault();
-
- const selectedUrl = new URL(e.target.href);
- const loc = window.location.href;
-
- if (loc.includes('/-/')) {
- const currentRef = $dropdown.data('ref');
- // The split and startWith is to ensure an exact word match
- // and avoid partial match ie. currentRef is "dev" and loc is "development"
- const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1];
- const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/');
-
- if (doesPathContainRef) {
- // We are ignoring the url containing the ref portion
- // and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
- selectedUrl.searchParams.set('path', targetPath);
- selectedUrl.hash = window.location.hash;
- }
- }
-
- // Open in new window if "meta" key is pressed
- if (e.metaKey) {
- window.open(selectedUrl.href, '_blank');
- } else {
- window.location.href = selectedUrl.href;
- }
- }
- },
- });
- });
- }
}
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index d2263fa815d..087808c33da 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,3 +1,4 @@
+import 'bootstrap/js/dist/collapse';
import MirrorRepos from '~/mirrors/mirror_repos';
import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules';
import mountDefaultBranchSelector from '~/projects/settings/mount_default_branch_selector';
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
index 885b8ca8e12..d907b8a470d 100644
--- a/app/assets/javascripts/pages/projects/usage_quotas/index.js
+++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js
@@ -1,9 +1,21 @@
import initProjectStorage from '~/usage_quotas/storage/init_project_storage';
import initSearchSettings from '~/search_settings';
+import { GlTabsBehavior, HISTORY_TYPE_HASH } from '~/tabs';
+
+const initGlTabs = () => {
+ const tabsEl = document.getElementById('js-project-usage-quotas-tabs');
+ if (!tabsEl) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+};
const initVueApp = () => {
initProjectStorage('js-project-storage-count-app');
};
+initGlTabs();
initVueApp();
initSearchSettings();
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index b8de2757284..00f7c5d60d1 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -5,6 +5,7 @@ import LengthValidator from '~/validators/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
import EmailFormatValidator from '~/pages/sessions/new/email_format_validator';
import { initLanguageSwitcher } from '~/language_switcher';
+import { initTogglePasswordVisibility } from '~/authentication/password';
import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
@@ -19,3 +20,4 @@ Tracking.enableFormTracking({
});
initLanguageSwitcher();
+initTogglePasswordVisibility();
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index ee48543f0d2..bad8a7cedc6 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -14,7 +14,7 @@ export default class OAuthRememberMe {
}
bindEvents() {
- $('#remember_me', this.container).on('click', this.toggleRememberMe);
+ $('#remember_me_omniauth', this.container).on('click', this.toggleRememberMe);
}
toggleRememberMe(event) {
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 549c964cce4..3b38d715ea5 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -128,6 +128,9 @@ export default {
};
},
computed: {
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noContent() {
return !this.content.trim();
},
@@ -351,6 +354,8 @@ export default {
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
+ :enable-autocomplete="true"
+ :autocomplete-data-sources="autocompleteDataSources"
:drawio-enabled="true"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
diff --git a/app/assets/javascripts/pages/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
new file mode 100644
index 00000000000..41c78fbe3a6
--- /dev/null
+++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
@@ -0,0 +1,3 @@
+import initTimelogsApp from '~/time_tracking';
+
+initTimelogsApp();
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index dbca8bc9be7..fac070d6e47 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink, GlPopover } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { glEmojiTag } from '~/emoji';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -10,8 +11,10 @@ import RequestSelector from './request_selector.vue';
export default {
components: {
+ GlPopover,
AddRequest,
DetailedMetric,
+ GlLink,
RequestSelector,
},
directives: {
@@ -30,6 +33,10 @@ export default {
type: String,
required: true,
},
+ requestMethod: {
+ type: String,
+ required: true,
+ },
peekUrl: {
type: String,
required: true,
@@ -72,6 +79,11 @@ export default {
keys: ['request', 'body'],
},
{
+ metric: 'zkt',
+ header: s__('PerformanceBar|Zoekt calls'),
+ keys: ['request', 'body'],
+ },
+ {
metric: 'external-http',
title: 'external',
header: s__('PerformanceBar|External Http calls'),
@@ -103,9 +115,6 @@ export default {
this.currentRequestId = requestId;
},
},
- initialRequest() {
- return this.currentRequestId === this.requestId;
- },
hasHost() {
return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host;
},
@@ -124,24 +133,47 @@ export default {
const fileName = this.requests[0].displayName;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
+ showZoekt() {
+ return document.body.dataset.page === 'search:show';
+ },
+ showFlamegraphButtons() {
+ return this.isGetRequest(this.currentRequestId);
+ },
+ showMemoryReportButton() {
+ return this.isGetRequest(this.currentRequestId) && this.env === 'development';
+ },
memoryReportPath() {
- return mergeUrlParams({ performance_bar: 'memory' }, window.location.href);
+ return mergeUrlParams(
+ { performance_bar: 'memory' },
+ this.store.findRequest(this.currentRequestId).fullUrl,
+ );
},
},
+ created() {
+ if (!this.showZoekt) {
+ this.$options.detailedMetrics = this.$options.detailedMetrics.filter(
+ (item) => item.metric !== 'zkt',
+ );
+ }
+ },
mounted() {
this.currentRequest = this.requestId;
},
methods: {
+ glEmojiTag,
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
this.$emit('change-request', newRequestId);
},
- flamegraphPath(mode) {
+ flamegraphPath(mode, requestId) {
return mergeUrlParams(
{ performance_bar: 'flamegraph', stackprof_mode: mode },
- window.location.href,
+ this.store.findRequest(requestId).fullUrl,
);
},
+ isGetRequest(requestId) {
+ return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET';
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -159,8 +191,17 @@ export default {
class="current-host"
:class="{ canary: currentRequest.details.host.canary }"
>
- <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
- {{ currentRequest.details.host.hostname }}
+ <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
+ <gl-popover placement="bottom" target="canary-emoji" content="Canary" />
+ <span
+ id="host-emoji"
+ v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')"
+ ></span>
+ <gl-popover
+ placement="bottom"
+ target="host-emoji"
+ :content="currentRequest.details.host.hostname"
+ />
</span>
</div>
<detailed-metric
@@ -177,41 +218,45 @@ export default {
id="peek-view-trace"
class="view"
>
- <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
+ <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
s__('PerformanceBar|Trace')
- }}</a>
+ }}</gl-link>
</div>
<div v-if="currentRequest.details" id="peek-download" class="view">
- <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{
- s__('PerformanceBar|Download')
- }}</a>
+ <gl-link
+ class="gl-text-blue-200"
+ is-unsafe-link
+ :download="downloadName"
+ :href="downloadPath"
+ >{{ s__('PerformanceBar|Download') }}</gl-link
+ >
</div>
- <div
- v-if="currentRequest.details && env === 'development'"
- id="peek-memory-report"
- class="view"
- >
- <a class="gl-text-blue-200" :href="memoryReportPath">{{
+ <div v-if="showMemoryReportButton" id="peek-memory-report" class="view">
+ <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{
s__('PerformanceBar|Memory report')
- }}</a>
+ }}</gl-link>
</div>
- <div v-if="currentRequest.details" id="peek-flamegraph" class="view">
- <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
- <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
+ <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view">
+ <span id="flamegraph-emoji" class="gl-text-white-200">
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span>
+ </span>
+ <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" />
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{
s__('PerformanceBar|wall')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{
s__('PerformanceBar|cpu')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{
s__('PerformanceBar|object')
- }}</a>
+ }}</gl-link>
</div>
- <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
+ <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
s__('PerformanceBar|Stats')
- }}</a>
+ }}</gl-link>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 84fe14fe056..e3e48e61393 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -32,15 +32,21 @@ const initPerformanceBar = (el) => {
store,
env: performanceBarData.env,
requestId: performanceBarData.requestId,
+ requestMethod: performanceBarData.requestMethod,
peekUrl: performanceBarData.peekUrl,
- profileUrl: performanceBarData.profileUrl,
statsUrl: performanceBarData.statsUrl,
};
},
mounted() {
PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest);
- this.addRequest(this.requestId, window.location.href);
+ this.addRequest(
+ this.requestId,
+ window.location.href,
+ undefined,
+ undefined,
+ this.requestMethod,
+ );
this.loadRequestDetails(this.requestId);
},
beforeDestroy() {
@@ -56,12 +62,12 @@ const initPerformanceBar = (el) => {
this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- addRequest(requestId, requestUrl, operationName) {
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
- this.store.addRequest(requestId, requestUrl, operationName);
+ this.store.addRequest(requestId, requestUrl, operationName, requestParams, methodVerb);
},
loadRequestDetails(requestId) {
const request = this.store.findRequest(requestId);
@@ -145,8 +151,8 @@ const initPerformanceBar = (el) => {
store: this.store,
env: this.env,
requestId: this.requestId,
+ requestMethod: this.requestMethod,
peekUrl: this.peekUrl,
- profileUrl: this.profileUrl,
statsUrl: this.statsUrl,
},
on: {
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index e67143f3ede..3a9788d8ab6 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -14,11 +14,13 @@ export default class PerformanceBarService {
fireCallback,
requestId,
requestUrl,
+ requestParams,
operationName,
+ methodVerb,
] = PerformanceBarService.callbackParams(response, peekUrl);
if (fireCallback) {
- callback(requestId, requestUrl, operationName);
+ callback(requestId, requestUrl, operationName, requestParams, methodVerb);
}
return response;
@@ -35,11 +37,14 @@ export default class PerformanceBarService {
static callbackParams(response, peekUrl) {
const requestId = response.headers && response.headers['x-request-id'];
const requestUrl = response.config?.url;
+ const requestParams = response.config?.params;
+ const methodVerb = response.config?.method;
+
const cachedResponse =
response.headers && parseBoolean(response.headers['x-gitlab-from-cache']);
const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse;
const operationName = response.config?.operationName;
- return [fireCallback, requestId, requestUrl, operationName];
+ return [fireCallback, requestId, requestUrl, requestParams, operationName, methodVerb];
}
}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 2011604534c..34e2763a478 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -1,11 +1,22 @@
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
export default class PerformanceBarStore {
constructor() {
this.requests = [];
}
- addRequest(requestId, requestUrl, operationName) {
- if (!this.findRequest(requestId)) {
- let displayName = PerformanceBarStore.truncateUrl(requestUrl);
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
+ if (this.findRequest(requestId)) {
+ this.updateRequestBatchedQueriesCount(requestId);
+ } else {
+ let displayName = '';
+
+ if (methodVerb) {
+ displayName += `${methodVerb.toUpperCase()} `;
+ }
+
+ displayName += PerformanceBarStore.truncateUrl(requestUrl);
if (operationName) {
displayName += ` (${operationName})`;
@@ -14,13 +25,31 @@ export default class PerformanceBarStore {
this.requests.push({
id: requestId,
url: requestUrl,
+ fullUrl: mergeUrlParams(requestParams, requestUrl),
+ method: methodVerb,
details: {},
+ queriesInBatch: 1, // only for GraphQL
displayName,
});
}
return this.requests;
}
+ updateRequestBatchedQueriesCount(requestId) {
+ const existingRequest = this.findRequest(requestId);
+ existingRequest.queriesInBatch += 1;
+
+ const oldDisplayName = existingRequest.displayName;
+ const regex = /\d+ queries batched/;
+ if (regex.test(oldDisplayName)) {
+ existingRequest.displayName = oldDisplayName.replace(
+ regex,
+ `${existingRequest.queriesInBatch} queries batched`,
+ );
+ } else {
+ existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`);
+ }
+ }
findRequest(requestId) {
return this.requests.find((request) => request.id === requestId);
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 3130fe42c3c..c9f43e43b2d 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -24,6 +24,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-geo-migrate-hashed-storage-callout',
'.js-unlimited-members-during-trial-alert',
'.js-branch-rules-info-callout',
+ '.js-license-check-deprecation-alert',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 8f76d7535f1..83cd64c17ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -255,7 +255,7 @@ export default {
this.canRefetchHeaderPipeline = true;
this.$apollo.queries.headerPipeline.refetch();
},
- /* eslint-disable @gitlab/require-i18n-strings */
+ // eslint-disable-next-line @gitlab/require-i18n-strings
reportFailure({ type, err = 'No error string passed.', skipSentry = false }) {
this.showAlert = true;
this.alertType = type;
@@ -263,7 +263,6 @@ export default {
reportToSentry(this.$options.name, `type: ${type}, info: ${err}`);
}
},
- /* eslint-enable @gitlab/require-i18n-strings */
updateShowLinksState(val) {
this.showLinks = val;
},
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index 6d8c35f4482..73143c981ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -62,6 +62,7 @@ export default {
},
showTip() {
return (
+ this.showLinksToggle &&
this.showLinks &&
this.showLinksActive &&
!this.tipPreviouslyDismissed &&
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 3da792cb9df..54985a24593 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -35,7 +35,6 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => {
return layers;
};
-/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
return {
fetchOptions: {
@@ -52,6 +51,7 @@ const getQueryHeaders = (etagResource) => {
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
return `
${message}.
Locations: ${locations
@@ -74,14 +74,12 @@ const serializeLoadErrors = (errors) => {
}
if (!isEmpty(networkError)) {
- return `Network error: ${networkError.message}`;
+ return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings
}
return message;
};
-/* eslint-enable @gitlab/require-i18n-strings */
-
const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
const stopStartQuery = (query) => {
if (!Visibility.hidden()) {
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
index 16f6aa5aaa4..21b585933b8 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
@@ -13,7 +13,7 @@ export default {
FailedJobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
@@ -31,7 +31,7 @@ export default {
query: GetFailedJobsQuery,
variables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index 661de43fe3c..61748860983 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -17,7 +17,7 @@ export default {
JobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
@@ -56,7 +56,7 @@ export default {
computed: {
queryVariables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
iid: this.pipelineIid,
};
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
index 03a2eac89e4..a6297213402 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
@@ -1,19 +1,8 @@
<script>
-import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import {
- STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
- I18N,
-} from '~/ci/pipeline_editor/constants';
+import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants';
import Tracking from '~/tracking';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { isExperimentVariant } from '~/experimentation/utils';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import CiTemplates from './ci_templates.vue';
export default {
@@ -21,19 +10,12 @@ export default {
GlButton,
GlCard,
GlSprintf,
- GlIcon,
- GlLink,
- GitlabExperiment,
CiTemplates,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
I18N,
- inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'],
+ inject: ['pipelineEditorPath'],
data() {
return {
gettingStartedTemplateUrl: mergeUrlParams(
@@ -43,26 +25,12 @@ export default {
tracker: null,
};
},
- computed: {
- sharedRunnersHelpPagePath() {
- return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' });
- },
- runnersAvailabilitySectionExperimentEnabled() {
- return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
- },
- created() {
- this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
methods: {
trackEvent(template) {
this.track('template_clicked', {
label: template,
});
},
- trackExperimentEvent(action) {
- this.tracker.event(action);
- },
},
};
</script>
@@ -70,92 +38,42 @@ export default {
<div>
<h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
- <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME">
- <template #candidate>
- <div v-if="anyRunnersAvailable">
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" />
- {{ $options.I18N.runners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.runners.subtitle">
- <template #settingsLink="{ content }">
- <gl-link
- data-testid="settings-link"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- <template #docsLink="{ content }">
- <gl-link
- data-testid="documentation-link"
- :href="sharedRunnersHelpPagePath"
- @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </p>
- </div>
-
- <div v-else>
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" />
- {{ $options.I18N.noRunners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p>
- <gl-button
- data-testid="settings-button"
- category="primary"
- variant="confirm"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)"
- >
- {{ $options.I18N.noRunners.cta }}
- </gl-button>
- </div>
- </template>
- </gitlab-experiment>
-
- <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable">
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
- <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
- <gl-card>
- <div class="gl-flex-direction-row">
- <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800 gl-mb-2">
- {{ $options.I18N.learnBasics.gettingStarted.title }}
- </strong>
- </div>
- <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">
+ {{ $options.I18N.learnBasics.gettingStarted.title }}
+ </strong>
</div>
+ <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ </div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="gettingStartedTemplateUrl"
- data-testid="test-template-link"
- @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
- >
- {{ $options.I18N.learnBasics.gettingStarted.cta }}
- </gl-button>
- </gl-card>
- </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="gettingStartedTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
+ >
+ {{ $options.I18N.learnBasics.gettingStarted.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ci-templates />
- </template>
+ <ci-templates />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index dd62ffb27f7..caeee7edefe 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -36,12 +36,10 @@ export default {
};
},
computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
+ hasActions() {
+ return (
+ this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions
+ );
},
isCancelling() {
return this.cancelingPipeline === this.pipeline.id;
@@ -75,7 +73,7 @@ export default {
<template>
<div class="gl-text-right">
<div class="btn-group">
- <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" />
+ <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" />
<gl-button
v-if="pipeline.flags.retryable"
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 fe2ef2c2d71..7ad12d397e5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -144,14 +144,16 @@ export default {
<tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
:href="commitUrl"
- class="commit-row-message gl-text-gray-900"
+ class="commit-row-message gl-font-weight-bold gl-text-gray-900"
data-testid="commit-title"
@click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
>
</tooltip-on-truncate>
</span>
- <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
+ <span v-else class="gl-text-gray-500">{{
+ __("Can't find HEAD commit for this branch")
+ }}</span>
</div>
<div class="gl-mb-2">
<gl-link
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 640129b9c4c..c5537b7ad54 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -346,7 +346,7 @@ export default {
</div>
<div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex">
- <div class="row-content-block gl-display-flex gl-flex-grow-1">
+ <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0">
<pipelines-filtered-search
class="gl-display-flex gl-flex-grow-1 gl-mr-4"
:project-id="projectId"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 50d34070e61..262e82677a7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -8,8 +8,10 @@ import Tracking from '~/tracking';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
+import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql';
export default {
+ name: 'PipelinesManualActions',
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -18,22 +20,52 @@ export default {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLoadingIcon,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath', 'manualActionsLimit'],
props: {
- actions: {
- type: Array,
+ iid: {
+ type: Number,
required: true,
},
},
+ apollo: {
+ actions: {
+ query: getPipelineActionsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ limit: this.manualActionsLimit,
+ };
+ },
+ skip() {
+ return !this.hasDropdownBeenShown;
+ },
+ update({ project }) {
+ return project?.pipeline?.jobs?.nodes || [];
+ },
+ },
+ },
data() {
return {
isLoading: false,
+ actions: [],
+ hasDropdownBeenShown: false,
};
},
+ computed: {
+ isActionsLoading() {
+ return this.$apollo.queries.actions.loading;
+ },
+ isDropdownLimitReached() {
+ return this.actions.length === this.manualActionsLimit;
+ },
+ },
methods: {
async onClickAction(action) {
- if (action.scheduled_at) {
+ if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
@@ -54,12 +86,12 @@ export default {
* Ideally, the component would not make an api call directly.
* However, in order to use the eventhub and know when to
* toggle back the `isLoading` property we'd need an ID
- * to track the request with a wacther - since this component
+ * to track the request with a watcher - since this component
* is rendered at least 20 times in the same page, moving the
* api call directly here is the most performant solution
*/
axios
- .post(`${action.path}.json`)
+ .post(`${action.playPath}.json`)
.then(() => {
this.isLoading = false;
eventHub.$emit('updateTable');
@@ -69,12 +101,12 @@ export default {
createAlert({ message: __('An error occurred while making the request.') });
});
},
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ fetchActions() {
+ this.hasDropdownBeenShown = true;
+
+ this.$apollo.queries.actions.refetch();
- return !action.playable;
+ this.trackClick();
},
trackClick() {
this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table });
@@ -91,21 +123,37 @@ export default {
right
lazy
icon="play"
- @shown="trackClick"
+ @shown="fetchActions"
>
+ <gl-dropdown-item v-if="isActionsLoading">
+ <div class="gl-display-flex">
+ <gl-loading-icon class="mr-2" />
+ <span>{{ __('Loading...') }}</span>
+ </div>
+ </gl-dropdown-item>
+
<gl-dropdown-item
v-for="action in actions"
- :key="action.path"
- :disabled="isActionDisabled(action)"
+ v-else
+ :key="action.id"
+ :disabled="!action.canPlayJob"
@click="onClickAction(action)"
>
<div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
{{ action.name }}
- <span v-if="action.scheduled_at">
+ <span v-if="action.scheduledAt">
<gl-icon name="clock" />
- <gl-countdown :end-date-string="action.scheduled_at" />
+ <gl-countdown :end-date-string="action.scheduledAt" />
</span>
</div>
</gl-dropdown-item>
+
+ <template #footer>
+ <gl-dropdown-item v-if="isDropdownLimitReached">
+ <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg">
+ {{ __('Showing first 50 actions.') }}
+ </span>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
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 365572f194b..b2da0df17c0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -15,7 +15,7 @@ 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! gl-font-sm!';
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
export default {
components: {
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 960af030421..e15676849da 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -41,7 +41,7 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column time-ago">
+ <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"
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
new file mode 100644
index 00000000000..d1878c01e91
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
@@ -0,0 +1,24 @@
+query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs(
+ first: $limit
+ whenExecuted: ["manual", "delayed"]
+ retried: false
+ statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED]
+ ) {
+ nodes {
+ id
+ name
+ canPlayJob
+ manualJob
+ scheduledAt
+ scheduled
+ playPath
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index d94602c23b4..27debec7bb3 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -29,7 +29,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeLicenseScanningData,
failedJobsCount,
failedJobsSummary,
- fullPath,
+ projectPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
@@ -50,8 +50,6 @@ export const createAppOptions = (selector, apolloProvider, router) => {
testsCount,
} = dataset;
- // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved
- const projectPath = fullPath;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
return {
@@ -83,7 +81,6 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData),
failedJobsCount,
failedJobsSummary: JSON.parse(failedJobsSummary),
- fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 6dccdb1a3e6..49e2e1644e2 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -1,5 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import {
parseBoolean,
historyReplaceState,
@@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store';
Vue.use(Translate);
Vue.use(GlToast);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const el = document.querySelector(selector);
@@ -38,22 +45,22 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
projectId,
defaultBranchName,
params,
- ciRunnerSettingsPath,
- anyRunnersAvailable,
iosRunnersAvailable,
registrationToken,
+ fullPath,
} = el.dataset;
return new Vue({
el,
+ apolloProvider,
provide: {
pipelineEditorPath,
artifactsEndpoint,
artifactsEndpointPlaceholder,
suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
- ciRunnerSettingsPath,
- anyRunnersAvailable: parseBoolean(anyRunnersAvailable),
iosRunnersAvailable: parseBoolean(iosRunnersAvailable),
+ fullPath,
+ manualActionsLimit: 50,
},
data() {
return {
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index 76fb13919df..21f8a2d3500 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,18 +1,44 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
+ personalProjects: s__('UserProfile|Personal projects'),
+ viewAll: s__('UserProfile|View all'),
+ },
+ components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList },
+ props: {
+ personalProjects: {
+ type: Array,
+ required: true,
+ },
+ personalProjectsLoading: {
+ type: Boolean,
+ required: true,
+ },
},
- components: { GlTab, ActivityCalendar },
};
</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-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.personalProjects }}</h4>
+ <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
+ </div>
+ <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="personalProjects" />
+ </div>
+ </div>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index b39bfabb832..25b94d7dc7f 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -1,6 +1,10 @@
<script>
import { GlTabs } from '@gitlab/ui';
+import { getUserProjects } from '~/rest_api';
+import { s__ } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
import OverviewTab from './overview_tab.vue';
import ActivityTab from './activity_tab.vue';
import GroupsTab from './groups_tab.vue';
@@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue';
import FollowingTab from './following_tab.vue';
export default {
+ i18n: {
+ personalProjectsErrorMessage: s__(
+ 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.',
+ ),
+ },
components: {
GlTabs,
OverviewTab,
@@ -62,6 +71,22 @@ export default {
component: FollowingTab,
},
],
+ inject: ['userId'],
+ data() {
+ return {
+ personalProjectsLoading: true,
+ personalProjects: [],
+ };
+ },
+ async mounted() {
+ try {
+ const response = await getUserProjects(this.userId, { per_page: 10 });
+ this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true });
+ this.personalProjectsLoading = false;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.personalProjectsErrorMessage });
+ }
+ },
};
</script>
@@ -72,6 +97,8 @@ export default {
v-for="{ key, component } in $options.tabs"
:key="key"
class="container-fluid container-limited"
+ :personal-projects="personalProjects"
+ :personal-projects-loading="personalProjectsLoading"
/>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 050b004f657..107bfd159dd 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -3,6 +3,8 @@
import $ from 'jquery';
import 'cropper';
import { isString } from 'lodash';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
import { loadCSSFile } from '../lib/utils/css_utils';
(() => {
@@ -139,11 +141,20 @@ import { loadCSSFile } from '../lib/utils/css_utils';
}
readFile(input) {
- const _this = this;
const reader = new FileReader();
reader.onload = () => {
- _this.modalCropImg.attr('src', reader.result);
- return _this.modalCrop.modal('show');
+ this.modalCropImg.attr('src', reader.result);
+ import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal')
+ .then(() => {
+ this.modalCrop.modal('show');
+ })
+ .catch(() => {
+ createAlert({
+ message: s__(
+ 'UserProfile|Failed to set avatar. Please reload the page to try again.',
+ ),
+ });
+ });
};
return reader.readAsDataURL(input.files[0]);
}
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index fbe0e3534d8..101e52c873e 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -13,15 +13,17 @@ export const initProfileTabs = () => {
if (!el) return false;
- const { followees, followers, userCalendarPath, utcOffset } = el.dataset;
+ const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset;
return new Vue({
el,
+ name: 'ProfileRoot',
provide: {
followees: parseInt(followers, 10),
followers: parseInt(followees, 10),
userCalendarPath,
utcOffset,
+ userId,
},
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 0fd31381ba6..a4edc988d67 100644
--- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -93,7 +93,7 @@ export default {
data-testid="email-patches-link"
data-qa-selector="email_patches"
>
- {{ s__('DownloadCommit|Email Patches') }}
+ {{ __('Patches') }}
</gl-dropdown-item>
<gl-dropdown-item
:href="plainDiffPath"
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index f56884f605f..3179fcb14fd 100644
--- a/app/assets/javascripts/projects/commits/index.js
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -35,6 +35,10 @@ export const initCommitsRefSwitcher = () => {
const { projectId, ref, commitsPath, refType } = el.dataset;
const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0];
+ const generateRefDestinationUrl = (selectedRef, selectedRefType) => {
+ const commitsPathSuffix = selectedRefType ? `?ref_type=${selectedRefType}` : '';
+ return `${commitsPathPrefix}/${encodeURIComponent(selectedRef)}${commitsPathSuffix}`;
+ };
const useSymbolicRefNames = Boolean(refType);
return new Vue({
el,
@@ -48,15 +52,11 @@ export const initCommitsRefSwitcher = () => {
},
on: {
input(selected) {
- if (useSymbolicRefNames) {
- const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
- if (matches) {
- visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`);
- } else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
- }
+ const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
+ if (useSymbolicRefNames && matches) {
+ visitUrl(generateRefDestinationUrl(matches[2], matches[1]));
} else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
+ visitUrl(generateRefDestinationUrl(selected));
}
},
},
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index a44855c14d5..fb201576e85 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -121,4 +121,8 @@ export default {
text: s__('ProjectTemplates|TYPO3 Distribution'),
icon: '.template-option .icon-typo3',
},
+ laravel: {
+ text: s__('ProjectTemplates|Laravel Framework'),
+ icon: '.template-option .icon-laravel',
+ },
};
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 1599661505f..0a160a357e5 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -59,6 +59,10 @@ export default {
SafeHtml,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
projectsUrl: {
type: String,
required: true,
@@ -92,12 +96,14 @@ export default {
computed: {
initialBreadcrumbs() {
- return [
- this.parentGroupUrl
- ? { text: this.parentGroupName, href: this.parentGroupUrl }
- : { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
- { text: s__('ProjectsNew|New project'), href: '#' },
- ];
+ const breadcrumbs = this.parentGroupUrl
+ ? [{ text: this.parentGroupName, href: this.parentGroupUrl }]
+ : [
+ { text: s__('Navigation|Your work'), href: this.rootPath },
+ { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
+ ];
+ breadcrumbs.push({ text: s__('ProjectsNew|New project'), href: '#' });
+ return breadcrumbs;
},
availablePanels() {
return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL);
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 7330874eefe..5ec50355a82 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -18,6 +18,7 @@ export function initNewProjectCreation() {
parentGroupUrl,
parentGroupName,
projectsUrl,
+ rootPath,
} = el.dataset;
const props = {
@@ -27,6 +28,7 @@ export function initNewProjectCreation() {
parentGroupUrl,
parentGroupName,
projectsUrl,
+ rootPath,
};
const provide = {
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
index b0abe7ac463..dbcb77b67f3 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
@@ -187,6 +187,7 @@ export default {
:roles="pushAccessLevels.roles"
:users="pushAccessLevels.users"
:groups="pushAccessLevels.groups"
+ data-qa-selector="allowed_to_push_content"
/>
<!-- Allowed to merge -->
@@ -197,6 +198,7 @@ export default {
:roles="mergeAccessLevels.roles"
:users="mergeAccessLevels.users"
:groups="mergeAccessLevels.groups"
+ data-qa-selector="allowed_to_merge_content"
/>
<!-- Force push -->
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
index 721248e53e3..3a5b3409596 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
@@ -101,7 +101,13 @@ export default {
<div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div>
- <div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level">
+ <div
+ v-for="(item, index) in accessLevels"
+ :key="index"
+ data-testid="access-level"
+ data-qa-selector="access_level_content"
+ :data-qa-role="item.accessLevelDescription"
+ >
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
{{ item.accessLevelDescription }}
</div>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index 7709419b6f8..dcf5155644d 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -69,9 +69,14 @@ export default {
<div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div>
- <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{
- $options.i18n.addBranchRule
- }}</gl-button>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ class="gl-mt-5"
+ data-qa-selector="add_branch_rule_button"
+ category="secondary"
+ variant="info"
+ >{{ $options.i18n.addBranchRule }}</gl-button
+ >
<gl-modal
:ref="$options.modalId"
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
index b565bda247d..a5ff478a826 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -153,7 +153,11 @@ export default {
</script>
<template>
- <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between">
+ <div
+ class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"
+ data-qa-selector="branch_content"
+ :data-qa-branch-name="name"
+ >
<div>
<strong class="gl-font-monospace">{{ name }}</strong>
@@ -169,7 +173,7 @@ export default {
<li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
</ul>
</div>
- <gl-button class="gl-align-self-start" :href="detailsPath">
+ <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath">
{{ $options.i18n.detailsButtonLabel }}</gl-button
>
</div>
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 b79b3fa4573..79ece99e6ec 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
@@ -8,7 +8,7 @@ import ServiceDeskSetting from './service_desk_setting.vue';
export default {
customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
}),
components: {
GlAlert,
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 85550e262e6..8af2e787740 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
@@ -102,12 +102,12 @@ export default {
},
emailSuffixHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'configuring-a-custom-email-address-suffix',
+ anchor: 'configure-a-custom-email-address-suffix',
});
},
customEmailAddressHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
});
},
},
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index b5d00cb7e82..5342874250c 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -9,3 +9,9 @@ export const LEVEL_TYPES = {
GROUP: 'group',
DEPLOY_KEY: 'deploy_key',
};
+
+export const BRANCH_RULES_ANCHOR = '#branch-rules';
+
+export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created';
+
+export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings';
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index cd37c0de6a5..cdbe39fd5e0 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,17 +1,24 @@
import $ from 'jquery';
import CreateItemDropdown from '~/create_item_dropdown';
-import { createAlert } from '~/alert';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { initToggle } from '~/toggles';
-import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
+import { expandSection } from '~/settings_panels';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import {
+ BRANCH_RULES_ANCHOR,
+ PROTECTED_BRANCHES_ANCHOR,
+ IS_PROTECTED_BRANCH_CREATED,
+ ACCESS_LEVELS,
+ LEVEL_TYPES,
+} from './constants';
export default class ProtectedBranchCreate {
constructor(options) {
this.hasLicense = options.hasLicense;
-
this.$form = $('.js-new-protected-branch');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
@@ -22,7 +29,7 @@ export default class ProtectedBranchCreate {
if (this.hasLicense) {
this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle'));
}
-
+ this.showSuccessAlertIfNeeded();
this.bindEvents();
}
@@ -81,6 +88,49 @@ export default class ProtectedBranchCreate {
callback(gon.open_branches);
}
+ // eslint-disable-next-line class-methods-use-this
+ expandAndScroll(anchor) {
+ expandSection(anchor);
+ scrollToElement(anchor);
+ }
+
+ hasProtectedBranchSuccessAlert() {
+ return (
+ window.gon?.features?.branchRules &&
+ this.isLocalStorageAvailable &&
+ localStorage.getItem(IS_PROTECTED_BRANCH_CREATED)
+ );
+ }
+
+ createSuccessAlert() {
+ this.alert = createAlert({
+ variant: VARIANT_SUCCESS,
+ containerSelector: '.js-alert-protected-branch-created-container',
+ title: s__('ProtectedBranch|View protected branches as branch rules'),
+ message: s__('ProtectedBranch|Manage branch related settings in one area with branch rules.'),
+ primaryButton: {
+ text: s__('ProtectedBranch|View branch rule'),
+ clickHandler: () => {
+ this.expandAndScroll(BRANCH_RULES_ANCHOR);
+ },
+ },
+ secondaryButton: {
+ text: __('Dismiss'),
+ clickHandler: () => this.alert.dismiss(),
+ },
+ });
+ }
+
+ showSuccessAlertIfNeeded() {
+ if (!this.hasProtectedBranchSuccessAlert()) {
+ return;
+ }
+ this.expandAndScroll(PROTECTED_BRANCHES_ANCHOR);
+
+ this.createSuccessAlert();
+ localStorage.removeItem(IS_PROTECTED_BRANCH_CREATED);
+ }
+
getFormData() {
const formData = {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
@@ -127,6 +177,9 @@ export default class ProtectedBranchCreate {
axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
.then(() => {
+ if (this.isLocalStorageAvailable) {
+ localStorage.setItem(IS_PROTECTED_BRANCH_CREATED, 'true');
+ }
window.location.reload();
})
.catch(() =>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 9826124912b..9a84726d42f 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: '',
},
+ queryParams: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
refType: {
type: String,
required: false,
@@ -93,6 +98,7 @@ export default {
matches: (state) => state.matches,
lastQuery: (state) => state.query,
selectedRef: (state) => state.selectedRef,
+ params: (state) => state.params,
}),
...mapGetters(['isLoading', 'isQueryPossiblyASha']),
i18n() {
@@ -186,6 +192,7 @@ export default {
this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
+ this.setParams(this.queryParams);
this.$watch(
'enabledRefTypes',
@@ -206,6 +213,7 @@ export default {
...mapActions([
'setEnabledRefTypes',
'setUseSymbolicRefNames',
+ 'setParams',
'setProjectId',
'setSelectedRef',
]),
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index a6019f21e73..3d6b46abf52 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -5,6 +5,8 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes);
+export const setParams = ({ commit }, params) => commit(types.SET_PARAMS, params);
+
export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
@@ -29,7 +31,7 @@ export const search = ({ state, dispatch, commit }, query) => {
export const searchBranches = ({ commit, state }) => {
commit(types.REQUEST_START);
- Api.branches(state.projectId, state.query)
+ Api.branches(state.projectId, state.query, state.params)
.then((response) => {
commit(types.RECEIVE_BRANCHES_SUCCESS, response);
})
diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js
index 2bebffc19ab..fb2196fa1d0 100644
--- a/app/assets/javascripts/ref/stores/index.js
+++ b/app/assets/javascripts/ref/stores/index.js
@@ -14,3 +14,11 @@ export default () =>
mutations,
state: createState(),
});
+
+export const createRefModule = () => ({
+ namespaced: true,
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+});
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index 4c602908cae..6178106fe00 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,5 +1,6 @@
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
+export const SET_PARAMS = 'SET_PARAMS';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index 9846ac0adb7..43c4318ad6c 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -10,6 +10,9 @@ export default {
[types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
state.useSymbolicRefNames = useSymbolicRefNames;
},
+ [types.SET_PARAMS](state, params) {
+ state.params = params;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
index 3affa8f8d03..1619b43c02e 100644
--- a/app/assets/javascripts/ref/stores/state.js
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -15,5 +15,6 @@ export default () => ({
commits: createRefTypeState(),
},
selectedRef: null,
+ params: null,
requestCount: 0,
});
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 043d925198c..24b350c7f18 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -194,9 +194,11 @@ export default {
'gl-border-b-1': isOpen,
'gl-border-b-0': !isOpen,
}"
- class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
+ class="gl-display-flex gl-justify-content-space-between gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
>
- <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <h3
+ class="card-title h5 gl-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24"
+ >
<gl-link
id="user-content-related-issues"
class="anchor position-absolute gl-text-decoration-none"
diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue
new file mode 100644
index 00000000000..44269bccec9
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_create.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { uniqueId } from 'lodash';
+import { __, s__ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ RefSelector,
+ },
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+ props: {
+ value: { type: String, required: true },
+ },
+ data() {
+ return {
+ nameId: uniqueId('tag-name-'),
+ refId: uniqueId('ref-'),
+ messageId: uniqueId('message-'),
+ };
+ },
+ computed: {
+ ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ },
+ methods: {
+ ...mapActions('editNew', ['updateReleaseTagMessage', 'updateCreateFrom']),
+ },
+ i18n: {
+ tagNameLabel: __('Tag name'),
+ refLabel: __('Create from'),
+ messageLabel: s__('CreateGitTag|Set tag message'),
+ messagePlaceholder: s__(
+ 'CreateGitTag|Add a message to the tag. Leaving this blank creates a lightweight tag.',
+ ),
+ create: __('Save'),
+ cancel: s__('Release|Select another tag'),
+ refSelector: {
+ noRefSelected: __('No source selected'),
+ searchPlaceholder: __('Search branches, tags, and commits'),
+ dropdownHeader: __('Select source'),
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-p-3" data-testid="create-from-field">
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.tagNameLabel"
+ :label-for="nameId"
+ label-sr-only
+ >
+ <gl-form-input :id="nameId" :value="value" autofocus @input="$emit('change', $event)" />
+ </gl-form-group>
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.refLabel" :label-for="refId" label-sr-only>
+ <ref-selector
+ :id="refId"
+ :project-id="projectId"
+ :value="createFrom"
+ :translations="$options.i18n.refSelector"
+ @input="updateCreateFrom"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.messageLabel"
+ :label-for="messageId"
+ label-sr-only
+ >
+ <gl-form-textarea
+ :id="messageId"
+ :placeholder="$options.i18n.messagePlaceholder"
+ :no-resize="false"
+ :value="release.tagMessage"
+ @input="updateReleaseTagMessage"
+ />
+ </gl-form-group>
+ <gl-button class="gl-mr-3" variant="confirm" @click="$emit('create')">
+ {{ $options.i18n.create }}
+ </gl-button>
+ <gl-button @click="$emit('cancel')">{{ $options.i18n.cancel }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 2ac61988393..ec058cc3603 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,225 +1,133 @@
<script>
-import {
- GlCollapse,
- GlLink,
- GlFormGroup,
- GlFormTextarea,
- GlDropdownItem,
- GlSprintf,
-} from '@gitlab/ui';
-import { uniqueId } from 'lodash';
+import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__ } from '~/locale';
-import RefSelector from '~/ref/components/ref_selector.vue';
-import { REF_TYPE_TAGS } from '~/ref/constants';
-import FormFieldContainer from './form_field_container.vue';
+
+import TagSearch from './tag_search.vue';
+import TagCreate from './tag_create.vue';
export default {
- name: 'TagFieldNew',
components: {
- GlCollapse,
+ GlDropdown,
GlFormGroup,
- GlFormTextarea,
- GlLink,
- RefSelector,
- FormFieldContainer,
- GlDropdownItem,
- GlSprintf,
+ GlPopover,
+ TagSearch,
+ TagCreate,
},
data() {
- return {
- // Keeps track of whether or not the user has interacted with
- // the input field. This is used to avoid showing validation
- // errors immediately when the page loads.
- isInputDirty: false,
- };
+ return { id: 'release-tag-name', newTagName: '', show: false, isInputDirty: false };
},
computed: {
- ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']),
- ...mapGetters('editNew', ['validationErrors']),
- tagName: {
- get() {
- return this.release.tagName;
- },
- set(tagName) {
- this.updateReleaseTagName(tagName);
-
- // This setter is used by the `v-model` on the `RefSelector`.
- // When this is called, the selection originated from the
- // dropdown list of existing tag names, so we know the tag
- // already exists and don't need to show the "create from" input
- this.updateShowCreateFrom(false);
- },
- },
- tagMessage: {
- get() {
- return this.release.tagMessage;
- },
- set(tagMessage) {
- this.updateReleaseTagMessage(tagMessage);
- },
- },
- createFromModel: {
- get() {
- return this.createFrom;
- },
- set(createFrom) {
- this.updateCreateFrom(createFrom);
- },
+ ...mapState('editNew', ['release', 'showCreateFrom']),
+ ...mapGetters('editNew', ['validationErrors', 'isSearching', 'isCreating']),
+ title() {
+ return this.isCreating ? this.$options.i18n.createTitle : this.$options.i18n.selectTitle;
},
showTagNameValidationError() {
return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid;
},
- tagNameInputId() {
- return uniqueId('tag-name-input-');
- },
- createFromSelectorId() {
- return uniqueId('create-from-selector-');
- },
tagFeedback() {
return this.validationErrors.tagNameValidation.validationErrors[0];
},
+ buttonText() {
+ return this.release?.tagName || s__('Release|Search or create tag name');
+ },
+ buttonVariant() {
+ return this.showTagNameValidationError ? 'danger' : 'default';
+ },
+ createText() {
+ return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
+ },
},
methods: {
...mapActions('editNew', [
+ 'setSearching',
+ 'setCreating',
+ 'setNewTag',
+ 'setExistingTag',
'updateReleaseTagName',
- 'updateReleaseTagMessage',
- 'updateCreateFrom',
'fetchTagNotes',
- 'updateShowCreateFrom',
]),
- markInputAsDirty() {
- this.isInputDirty = true;
+ startCreate(query) {
+ this.newTagName = query;
+ this.setCreating();
},
- createTagClicked(newTagName) {
- this.updateReleaseTagName(newTagName);
+ selected(tag) {
+ this.updateReleaseTagName(tag);
- // This method is called when the user selects the "create tag"
- // option, so the tag does not already exist. Because of this,
- // we need to show the "create from" input.
- this.updateShowCreateFrom(true);
- },
- shouldShowCreateTagOption(isLoading, matches, query) {
- // Show the "create tag" option if:
- return (
- // we're not currently loading any results, and
- !isLoading &&
- // the search query isn't just whitespace, and
- query.trim() &&
- // the `matches` object is non-null, and
- matches &&
- // the tag name doesn't already exist
- !matches.tags.list.some(
- (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(),
- )
- );
+ if (this.isSearching) {
+ this.fetchTagNotes(tag);
+ this.setExistingTag();
+ this.newTagName = '';
+ } else {
+ this.setNewTag();
+ }
+
+ this.hidePopover();
},
- },
- translations: {
- tagName: {
- noRefSelected: __('No tag selected'),
- dropdownHeader: __('Tag name'),
- searchPlaceholder: __('Search or create tag'),
- label: __('Tag name'),
- labelDescription: __('*Required'),
+ markInputAsDirty() {
+ this.isInputDirty = true;
},
- createFrom: {
- noRefSelected: __('No source selected'),
- searchPlaceholder: __('Search branches, tags, and commits'),
- dropdownHeader: __('Select source'),
- label: __('Create from'),
- description: __('Existing branch name, tag, or commit SHA'),
+ showPopover() {
+ this.show = true;
},
- annotatedTag: {
- label: s__('CreateGitTag|Set tag message'),
- description: s__(
- 'CreateGitTag|Add a message to the tag. Leaving this blank creates a %{linkStart}lightweight tag%{linkEnd}.',
- ),
+ hidePopover() {
+ this.show = false;
},
},
- tagMessageId: uniqueId('tag-message-'),
-
- tagNameEnabledRefTypes: [REF_TYPE_TAGS],
- gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/',
+ i18n: {
+ selectTitle: __('Tags'),
+ createTitle: s__('Release|Create tag'),
+ label: __('Tag name'),
+ required: __('(required)'),
+ create: __('Create'),
+ cancel: __('Cancel'),
+ },
};
</script>
<template>
- <div>
+ <div class="row">
<gl-form-group
- data-testid="tag-name-field"
+ class="col-md-4 col-sm-10"
+ :label="$options.i18n.label"
+ :label-for="id"
+ :optional-text="$options.i18n.required"
:state="!showTagNameValidationError"
:invalid-feedback="tagFeedback"
- :label="$options.translations.tagName.label"
- :label-for="tagNameInputId"
- :label-description="$options.translations.tagName.labelDescription"
+ optional
+ data-testid="tag-name-field"
>
- <form-field-container>
- <ref-selector
- :id="tagNameInputId"
- v-model="tagName"
- :project-id="projectId"
- :translations="$options.translations.tagName"
- :enabled-ref-types="$options.tagNameEnabledRefTypes"
- :state="!showTagNameValidationError"
- @input="fetchTagNotes"
- @hide.once="markInputAsDirty"
- >
- <template #footer="{ isLoading, matches, query }">
- <gl-dropdown-item
- v-if="shouldShowCreateTagOption(isLoading, matches, query)"
- is-check-item
- :is-checked="tagName === query"
- @click="createTagClicked(query)"
- >
- <gl-sprintf :message="__('Create tag %{tagName}')">
- <template #tagName>
- <b>{{ query }}</b>
- </template>
- </gl-sprintf>
- </gl-dropdown-item>
- </template>
- </ref-selector>
- </form-field-container>
+ <gl-dropdown
+ :id="id"
+ :variant="buttonVariant"
+ :text="buttonText"
+ :toggle-class="['gl-text-gray-900!']"
+ category="secondary"
+ class="gl-w-30"
+ @show.prevent="showPopover"
+ />
+ <gl-popover
+ :show="show"
+ :target="id"
+ :title="title"
+ :css-classes="['gl-z-index-200', 'release-tag-selector']"
+ placement="bottom"
+ triggers="manual"
+ container="content-body"
+ show-close-button
+ @close-button-clicked="hidePopover"
+ @hide.once="markInputAsDirty"
+ >
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200">
+ <tag-create
+ v-if="isCreating"
+ v-model="newTagName"
+ @create="selected(newTagName)"
+ @cancel="setSearching"
+ />
+ <tag-search v-else v-model="newTagName" @create="startCreate" @select="selected" />
+ </div>
+ </gl-popover>
</gl-form-group>
- <gl-collapse :visible="showCreateFrom">
- <div class="gl-pl-6 gl-border-l-1 gl-border-l-solid gl-border-gray-300">
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.createFrom.label"
- :label-for="createFromSelectorId"
- data-testid="create-from-field"
- >
- <form-field-container>
- <ref-selector
- :id="createFromSelectorId"
- v-model="createFromModel"
- :project-id="projectId"
- :translations="$options.translations.createFrom"
- />
- </form-field-container>
- <template #description>{{ $options.translations.createFrom.description }}</template>
- </gl-form-group>
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.annotatedTag.label"
- :label-for="$options.tagMessageId"
- data-testid="annotated-tag-message-field"
- >
- <gl-form-textarea :id="$options.tagMessageId" v-model="tagMessage" />
- <template #description>
- <gl-sprintf :message="$options.translations.annotatedTag.description">
- <template #link="{ content }">
- <gl-link
- :href="$options.gitTagDocsLink"
- rel="noopener noreferrer"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </template>
- </gl-form-group>
- </div>
- </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue
new file mode 100644
index 00000000000..33b44c90e1f
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_search.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { debounce } from 'lodash';
+import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlSprintf,
+ },
+ model: {
+ prop: 'query',
+ event: 'change',
+ },
+ props: {
+ query: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return { tagName: '' };
+ },
+ computed: {
+ ...mapState('ref', ['matches']),
+ ...mapState('editNew', ['projectId', 'release']),
+ tags() {
+ return this.matches?.tags?.list || [];
+ },
+ createText() {
+ return this.query ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
+ },
+ selectedNotShown() {
+ return this.release.tagName && !this.tags.some((tag) => tag.name === this.release.tagName);
+ },
+ },
+ created() {
+ this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
+ },
+ mounted() {
+ this.setProjectId(this.projectId);
+ this.setEnabledRefTypes([REF_TYPE_TAGS]);
+ this.search(this.query);
+ },
+ methods: {
+ ...mapActions('ref', ['setEnabledRefTypes', 'setProjectId', 'search']),
+ onSearchBoxInput(searchQuery = '') {
+ const query = searchQuery.trim();
+ this.$emit('change', query);
+ this.debouncedSearch(query);
+ },
+ selected(tagName) {
+ return (this.release?.tagName ?? '') === tagName;
+ },
+ },
+ i18n: {
+ noResults: __('No results found'),
+ createTag: s__('Release|Create tag %{tag}'),
+ typeNew: s__('Release|Or type a new tag name'),
+ },
+};
+</script>
+<template>
+ <div data-testid="tag-name-search">
+ <gl-search-box-by-type
+ :value="query"
+ class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"
+ borderless
+ autofocus
+ @input="onSearchBoxInput"
+ />
+ <div class="gl-overflow-y-auto release-tag-list">
+ <div v-if="tags.length || release.tagName">
+ <gl-dropdown-item
+ v-if="selectedNotShown"
+ is-checked
+ is-check-item
+ class="gl-list-style-none"
+ >
+ {{ release.tagName }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-for="tag in tags"
+ :key="tag.name"
+ :is-checked="selected(tag.name)"
+ is-check-item
+ class="gl-list-style-none"
+ @click="$emit('select', tag.name)"
+ >
+ {{ tag.name }}
+ </gl-dropdown-item>
+ </div>
+ <div
+ v-else
+ class="gl-my-5 gl-text-gray-500 gl-display-flex gl-font-base gl-justify-content-center"
+ >
+ {{ $options.i18n.noResults }}
+ </div>
+ </div>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200 gl-py-3">
+ <gl-button
+ category="tertiary"
+ class="gl-justify-content-start! gl-rounded-0!"
+ block
+ :disabled="!query"
+ @click="$emit('create', query)"
+ >
+ <gl-sprintf :message="createText">
+ <template #tag>
+ <span class="gl-font-weight-bold">{{ query }}</span>
+ </template>
+ </gl-sprintf>
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index 0a3f8b5e63b..efd82edcdf0 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import { createRefModule } from '../ref/stores';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
import createEditNewModule from './stores/modules/edit_new';
@@ -12,6 +13,7 @@ export default () => {
const store = createStore({
modules: {
editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }),
+ ref: createRefModule(),
},
});
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index a7d8825ed33..f5191e000f7 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -274,3 +274,9 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
});
});
};
+
+export const setSearching = ({ commit }) => commit(types.SET_SEARCHING);
+export const setCreating = ({ commit }) => commit(types.SET_CREATING);
+
+export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG);
+export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG);
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/constants.js b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
new file mode 100644
index 00000000000..0f12f150525
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
@@ -0,0 +1,4 @@
+export const SEARCH = 'SEARCH';
+export const CREATE = 'CREATE';
+export const EXISTING_TAG = 'EXISTING_TAG';
+export const NEW_TAG = 'NEW_TAG';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 8ff479058f2..edf6c81c9e9 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -4,6 +4,7 @@ import { hasContent } from '~/lib/utils/text_utility';
import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
import { validateTag, ValidationResult } from '~/lib/utils/ref_validator';
import { i18n } from '~/releases/constants';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
/**
* @param {Object} link The link to test
@@ -169,10 +170,23 @@ export const releaseDeleteMutationVariables = (state) => ({
},
});
-export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) =>
- includeTagNotes && tagNotes
- ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
+export const formattedReleaseNotes = ({
+ includeTagNotes,
+ release: { description, tagMessage },
+ tagNotes,
+ showCreateFrom,
+}) => {
+ const notes = showCreateFrom ? tagMessage : tagNotes;
+ return includeTagNotes && notes
+ ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n`
: description;
+};
export const releasedAtChanged = ({ originalReleasedAt, release }) =>
originalReleasedAt !== release.releasedAt;
+
+export const isSearching = ({ step }) => step === SEARCH;
+export const isCreating = ({ step }) => step === CREATE;
+
+export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG;
+export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index e52eccd6a21..fc450970cde 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
@@ -29,3 +29,9 @@ export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR';
export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES';
export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT';
+
+export const SET_SEARCHING = 'SET_SEARCHING';
+export const SET_CREATING = 'SET_CREATING';
+
+export const SET_EXISTING_TAG = 'SET_EXISTING_TAG';
+export const SET_NEW_TAG = 'SET_NEW_TAG';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index ccd168aafc9..7ff18245a80 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -1,6 +1,7 @@
import { uniqueId, cloneDeep } from 'lodash';
import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants';
import * as types from './mutation_types';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
const findReleaseLink = (release, id) => {
return release.assets.links.find((l) => l.id === id);
@@ -127,4 +128,17 @@ export default {
[types.UPDATE_RELEASED_AT](state, releasedAt) {
state.release.releasedAt = releasedAt;
},
+
+ [types.SET_SEARCHING](state) {
+ state.step = SEARCH;
+ },
+ [types.SET_CREATING](state) {
+ state.step = CREATE;
+ },
+ [types.SET_EXISTING_TAG](state) {
+ state.tagStep = EXISTING_TAG;
+ },
+ [types.SET_NEW_TAG](state) {
+ state.tagStep = NEW_TAG;
+ },
};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 3112becfa9e..7bd3968dd93 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -1,3 +1,5 @@
+import { SEARCH, EXISTING_TAG } from './constants';
+
export default ({
isExistingRelease,
projectId,
@@ -62,4 +64,6 @@ export default ({
includeTagNotes: false,
existingRelease: null,
originalReleasedAt: new Date(),
+ step: SEARCH,
+ tagStep: EXISTING_TAG,
});
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 236351005e7..334e7964bc2 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -34,12 +34,14 @@ export default {
ForkSuggestion,
WebIdeLink,
CodeIntelligence,
+ AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
},
+ explainCodeAvailable: { default: false },
},
apollo: {
projectInfo: {
@@ -142,6 +144,9 @@ export default {
};
},
computed: {
+ shouldRenderGenie() {
+ return this.explainCodeAvailable;
+ },
isLoggedIn() {
return isLoggedIn();
},
@@ -316,9 +321,9 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-relative">
<gl-loading-icon v-if="isLoading" size="sm" />
- <div v-if="blobInfo && !isLoading" class="file-holder">
+ <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder">
<blob-header
:blob="blobInfo"
:hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs"
@@ -393,5 +398,11 @@ export default {
:wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
+ <ai-genie
+ v-if="shouldRenderGenie"
+ container-id="fileHolder"
+ :file-path="path"
+ class="gl-ml-7"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 1a834ba1d82..f3dbf98312e 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,13 +1,15 @@
<script>
import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import { createAlert } from '~/alert';
+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,
POLLING_INTERVAL_BACKOFF,
FIVE_MINUTES_IN_MS,
+ FORK_UPDATED_EVENT,
} from '../constants';
import forkDetailsQuery from '../queries/fork_details.query.graphql';
import ConflictsModal from './fork_sync_conflicts_modal.vue';
@@ -22,7 +24,11 @@ export const i18n = {
behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'),
error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'),
- sync: s__('ForksDivergence|Update fork'),
+ updateFork: s__('ForksDivergence|Update fork'),
+ createMergeRequest: s__('ForksDivergence|Create merge request'),
+ successMessage: s__(
+ 'ForksDivergence|Successfully fetched and merged from the upstream repository.',
+ ),
};
export default {
@@ -55,7 +61,16 @@ export default {
});
},
result({ loading }) {
- this.handlePolingInterval(loading);
+ if (!loading && this.isSyncing) {
+ this.increasePollInterval();
+ }
+ if (this.isForkUpdated) {
+ createAlert({
+ message: this.$options.i18n.successMessage,
+ variant: VARIANT_INFO,
+ });
+ eventHub.$emit(FORK_UPDATED_EVENT);
+ }
},
pollInterval() {
return this.pollInterval;
@@ -86,6 +101,11 @@ export default {
required: false,
default: '',
},
+ canSyncBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
aheadComparePath: {
type: String,
required: false,
@@ -96,12 +116,21 @@ export default {
required: false,
default: '',
},
+ createMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ canUserCreateMrInFork: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
project: {},
currentPollInterval: null,
- isSyncTriggered: false,
};
},
computed: {
@@ -126,6 +155,9 @@ export default {
isSyncing() {
return this.forkDetails?.isSyncing;
},
+ isForkUpdated() {
+ return this.isUpToDate && this.currentPollInterval;
+ },
ahead() {
return this.project?.forkDetails?.ahead;
},
@@ -163,12 +195,16 @@ export default {
hasBehindAheadMessage() {
return this.behindAheadMessage.length > 0;
},
- isSyncButtonAvailable() {
+ hasUpdateButton() {
return (
this.glFeatures.synchronizeFork &&
+ this.canSyncBranch &&
((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
);
},
+ hasCreateMrButton() {
+ return this.canUserCreateMrInFork && this.ahead && this.createMrPath;
+ },
forkDivergenceMessage() {
if (!this.forkDetails) {
return this.$options.i18n.limitedVisibility;
@@ -186,9 +222,8 @@ export default {
},
watch: {
hasConflicts(newVal) {
- if (newVal && this.isSyncTriggered) {
+ if (newVal && this.currentPollInterval) {
this.showConflictsModal();
- this.isSyncTriggered = false;
}
},
},
@@ -227,7 +262,6 @@ export default {
this.$refs.modal.show();
},
startSyncing() {
- this.isSyncTriggered = true;
this.syncForkWithPolling();
},
checkIfSyncIsPossible() {
@@ -237,18 +271,11 @@ export default {
this.startSyncing();
}
},
- handlePolingInterval(loading) {
- if (!loading && this.isSyncing) {
- const backoff = POLLING_INTERVAL_BACKOFF;
- const interval = this.currentPollInterval;
- const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS);
- this.currentPollInterval = this.currentPollInterval
- ? newInterval
- : POLLING_INTERVAL_DEFAULT;
- }
- if (this.currentPollInterval === FIVE_MINUTES_IN_MS) {
- this.$apollo.queries.forkDetailsQuery.stopPolling();
- }
+ increasePollInterval() {
+ const backoff = POLLING_INTERVAL_BACKOFF;
+ const interval = this.currentPollInterval;
+ const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS);
+ this.currentPollInterval = this.currentPollInterval ? newInterval : POLLING_INTERVAL_DEFAULT;
},
},
};
@@ -283,16 +310,29 @@ export default {
>
{{ $options.i18n.inaccessibleProject }}
</div>
- <gl-button
- v-if="isSyncButtonAvailable"
- :disabled="forkDetails.isSyncing"
- @click="checkIfSyncIsPossible"
- >
- <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
- <span>{{ $options.i18n.sync }}</span>
- </gl-button>
+ <div class="gl-display-flex gl-xs-display-none!">
+ <gl-button
+ v-if="hasCreateMrButton"
+ class="gl-ml-4"
+ :href="createMrPath"
+ data-testid="create-mr-button"
+ >
+ <span>{{ $options.i18n.createMergeRequest }}</span>
+ </gl-button>
+ <gl-button
+ v-if="hasUpdateButton"
+ class="gl-ml-4"
+ :disabled="forkDetails.isSyncing"
+ data-testid="update-fork-button"
+ @click="checkIfSyncIsPossible"
+ >
+ <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
+ <span>{{ $options.i18n.updateFork }}</span>
+ </gl-button>
+ </div>
<conflicts-modal
ref="modal"
+ :selected-branch="selectedBranch"
:source-name="sourceName"
:source-path="sourcePath"
:source-default-branch="sourceDefaultBranch"
diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
index 0bfb90bb3ec..ffe4fd4cd38 100644
--- a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
+++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
@@ -19,10 +19,9 @@ export const i18n = {
"ForksDivergence|Fetch the latest changes from the upstream repository's default branch:",
),
step2Text: s__(
- "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.",
+ "ForksDivergence|Check out to a branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.",
),
step3Text: s__('ForksDivergence|Push the updates to remote:'),
- step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."),
copyToClipboard: __('Copy to clipboard'),
close: __('Close'),
};
@@ -53,12 +52,20 @@ export default {
required: false,
default: '',
},
+ selectedBranch: {
+ type: String,
+ required: true,
+ default: '',
+ },
},
computed: {
instructionsStep1() {
const baseUrl = getBaseURL();
return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`;
},
+ instructionsStep2() {
+ return `git checkout ${this.selectedBranch}\ngit merge FETCH_HEAD`;
+ },
},
methods: {
show() {
@@ -69,9 +76,7 @@ export default {
},
},
i18n,
- instructionsStep2: 'git checkout -b &lt;new-branch-name&gt;\ngit merge FETCH_HEAD',
- instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD',
- instructionsStep3: 'git commit\ngit push',
+ instructionsStep3: 'git push',
};
</script>
<template>
@@ -100,14 +105,12 @@ export default {
<b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }}
</p>
<div class="gl-display-flex gl-mb-4">
- <pre
- class="gl-w-full gl-mb-0 gl-mr-3"
- data-testid="resolve-conflict-instructions"
- v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */"
- ></pre>
+ <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{
+ instructionsStep2
+ }}</pre>
<modal-copy-button
modal-id="fork-sync-conflicts-modal"
- :text="$options.instructionsStep2Clipboard"
+ :text="instructionsStep2"
:title="$options.i18n.copyToClipboard"
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
/>
@@ -127,9 +130,6 @@ export default {
class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3"
/>
</div>
- <p>
- <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }}
- </p>
<template #modal-footer>
<gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button>
</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 2d2e21dfd92..82dd1fda2a0 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -12,6 +12,8 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima
import SignatureBadge from '~/commit/components/signature_badge.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
+import eventHub from '../event_hub';
+import { FORK_UPDATED_EVENT } from '../constants';
export default {
components: {
@@ -97,10 +99,19 @@ export default {
this.commit = null;
},
},
+ mounted() {
+ eventHub.$on(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
+ beforeDestroy() {
+ eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
},
+ refetchLastCommit() {
+ this.$apollo.queries.commit.refetch();
+ },
},
defaultAvatarUrl,
safeHtmlConfig: {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index a6191203b2f..b711f671850 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -112,3 +112,5 @@ export const POLLING_INTERVAL_DEFAULT = 2500;
export const POLLING_INTERVAL_BACKOFF = 2;
export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal';
+
+export const FORK_UPDATED_EVENT = 'fork:updated';
diff --git a/app/assets/javascripts/repository/event_hub.js b/app/assets/javascripts/repository/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/repository/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 0db9dcb43df..5a3958d8e4a 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, {
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
+ const {
+ projectPath,
+ projectShortPath,
+ ref,
+ escapedRef,
+ fullName,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -70,11 +79,15 @@ export default function setupVueRepositoryList() {
return null;
}
const {
+ selectedBranch,
sourceName,
sourcePath,
sourceDefaultBranch,
+ createMrPath,
+ canSyncBranch,
aheadComparePath,
behindComparePath,
+ canUserCreateMrInFork,
} = forkEl.dataset;
return new Vue({
el: forkEl,
@@ -82,13 +95,16 @@ export default function setupVueRepositoryList() {
render(h) {
return h(ForkInfo, {
props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
projectPath,
- selectedBranch: ref,
+ selectedBranch,
sourceName,
sourcePath,
sourceDefaultBranch,
aheadComparePath,
behindComparePath,
+ createMrPath,
+ canUserCreateMrInFork,
},
});
},
@@ -138,6 +154,7 @@ export default function setupVueRepositoryList() {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -151,8 +168,8 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
initBlobControlsApp();
- initForkInfo();
initRefSwitcher();
+ initForkInfo();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
@@ -273,6 +290,7 @@ export default function setupVueRepositoryList() {
store: createStore(),
router,
apolloProvider,
+ provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) },
render(h) {
return h(App);
},
diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue
deleted file mode 100644
index 3ad5642afc7..00000000000
--- a/app/assets/javascripts/saved_replies/components/list_item.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { uniqueId } from 'lodash';
-import { GlButton, GlModal, GlModalDirective, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
-
-export default {
- components: {
- GlButton,
- GlModal,
- GlSprintf,
- },
- directives: {
- GlModal: GlModalDirective,
- GlTooltip: GlTooltipDirective,
- },
- props: {
- reply: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- isDeleting: false,
- modalId: uniqueId('delete-saved-reply-'),
- };
- },
- computed: {
- id() {
- return getIdFromGraphQLId(this.reply.id);
- },
- },
- methods: {
- onDelete() {
- this.isDeleting = true;
-
- this.$apollo.mutate({
- mutation: deleteSavedReplyMutation,
- variables: {
- id: this.reply.id,
- },
- update: (cache) => {
- const cacheId = cache.identify(this.reply);
- cache.evict({ id: cacheId });
- },
- });
- },
- },
- actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
- actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
-};
-</script>
-
-<template>
- <li class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center">
- <strong data-testid="saved-reply-name">{{ reply.name }}</strong>
- <div class="gl-ml-auto">
- <gl-button
- v-gl-tooltip
- :to="{ name: 'edit', params: { id: id } }"
- icon="pencil"
- :title="__('Edit')"
- :aria-label="__('Edit')"
- class="gl-mr-3"
- data-testid="saved-reply-edit-btn"
- />
- <gl-button
- v-gl-modal="modalId"
- v-gl-tooltip
- icon="remove"
- :aria-label="__('Delete')"
- :title="__('Delete')"
- variant="danger"
- category="secondary"
- data-testid="saved-reply-delete-btn"
- :loading="isDeleting"
- />
- </div>
- </div>
- <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
- <gl-modal
- :title="__('Delete saved reply')"
- :action-primary="$options.actionPrimary"
- :action-secondary="$options.actionSecondary"
- :modal-id="modalId"
- size="sm"
- @primary="onDelete"
- >
- <gl-sprintf
- :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
- >
- <template #name
- ><strong>{{ reply.name }}</strong></template
- >
- </gl-sprintf>
- </gl-modal>
- </li>
-</template>
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d71785d7fac..1e4b1e36514 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -15,6 +15,7 @@ export const initSearchApp = () => {
const store = createStore({
query,
navigation,
+ useNewNavigation: 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 60de63c7d7a..317145d4cd1 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,21 +1,23 @@
<script>
import { mapState, mapGetters } from 'vuex';
import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ScopeNewNavigation from '~/search/sidebar/components/scope_new_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 LanguageFilter from './language_filter.vue';
+import LanguageFilter from './language_filter/index.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
ResultsFilters,
ScopeNavigation,
+ ScopeNewNavigation,
LanguageFilter,
+ SidebarPortal,
},
- mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['urlQuery']),
+ ...mapState(['urlQuery', 'useNewNavigation']),
...mapGetters(['currentScope']),
showIssueAndMergeFilters() {
return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
@@ -23,12 +25,23 @@ export default {
showBlobFilter() {
return this.currentScope === SCOPE_BLOB;
},
+ showOldNavigation() {
+ return Boolean(this.currentScope);
+ },
},
};
</script>
<template>
+ <section v-if="useNewNavigation">
+ <sidebar-portal>
+ <scope-new-navigation />
+ <results-filters v-if="showIssueAndMergeFilters" />
+ <language-filter v-if="showBlobFilter" />
+ </sidebar-portal>
+ </section>
<section
+ v-else
class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5"
>
<scope-navigation />
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
index f7873a994aa..feff3f77dd2 100644
--- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
@@ -1,10 +1,15 @@
<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: {
@@ -16,9 +21,13 @@ export default {
type: Object,
required: true,
},
+ trackingNamespace: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
...mapGetters(['queryLanguageFilters']),
dataFilters() {
return Object.values(this.filtersData?.filters || []);
@@ -30,8 +39,11 @@ export default {
get() {
return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
},
- set(value) {
+ async set(value) {
this.setQuery({ key: this.filtersData?.filterParam, value });
+
+ await Vue.nextTick();
+ this.trackSelectCheckbox();
},
},
labelCountClasses() {
@@ -40,9 +52,15 @@ export default {
},
methods: {
...mapActions(['setQuery']),
- getFormatedCount(count) {
+ 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,
@@ -51,7 +69,7 @@ export default {
<template>
<div class="gl-mx-5">
- <h5 class="gl-mt-0">{{ filtersData.header }}</h5>
+ <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5>
<gl-form-checkbox-group v-model="selectedFilter">
<gl-form-checkbox
v-for="f in dataFilters"
@@ -67,7 +85,7 @@ export default {
{{ f.label }}
</span>
<span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
- {{ getFormatedCount(f.count) }}
+ {{ getFormattedCount(f.count) }}
</span>
</span>
</gl-form-checkbox>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index e7aa3d61409..56e44d454a1 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
confidentialFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
index df44a58a14b..df44a58a14b 100644
--- a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
index b2f8d3e1f5f..40b50f657f0 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
@@ -3,10 +3,18 @@ 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 { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data';
-import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants';
-import { convertFiltersData } from '../utils';
-import CheckboxFilter from './checkbox_filter.vue';
+import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
+import { convertFiltersData } from '../../utils';
+import CheckboxFilter from '../checkbox_filter.vue';
+import {
+ trackShowMore,
+ trackShowHasOverMax,
+ trackSubmitQuery,
+ trackResetQuery,
+ TRACKING_ACTION_SELECT,
+} from './tracking';
+
+import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data';
export default {
name: 'LanguageFilter',
@@ -30,7 +38,7 @@ export default {
reset: s__('GlobalSearch|Reset filters'),
},
computed: {
- ...mapState(['aggregations', 'sidebarDirty']),
+ ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']),
...mapGetters([
'languageAggregationBuckets',
'currentUrlQueryHasLanguageFilters',
@@ -76,11 +84,21 @@ export default {
]),
onShowMore() {
this.showAll = true;
+ trackShowMore();
+
+ if (this.hasOverMax) {
+ trackShowHasOverMax();
+ }
+ },
+ submitQuery() {
+ trackSubmitQuery();
+ this.applyQuery();
},
trimBuckets(length) {
return this.languageAggregationBuckets.slice(0, length);
},
cleanResetFilters() {
+ trackResetQuery();
if (this.currentUrlQueryHasLanguageFilters) {
return this.resetLanguageQueryWithRedirect();
}
@@ -89,6 +107,7 @@ export default {
},
},
HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_SELECT,
};
</script>
@@ -96,15 +115,18 @@ export default {
<gl-form
v-if="hasBuckets"
class="gl-pt-5 gl-md-pt-0 language-filter-checkbox"
- @submit.prevent="applyQuery"
+ @submit.prevent="submitQuery"
>
- <hr :class="dividerClasses" />
+ <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 }"
>
- <checkbox-filter :filters-data="filtersData" />
+ <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>
@@ -125,7 +147,7 @@ export default {
</gl-button>
</div>
<div v-if="!aggregations.error">
- <hr :class="$options.HR_DEFAULT_CLASSES" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5"
>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
new file mode 100644
index 00000000000..db107830329
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
@@ -0,0 +1,39 @@
+import Tracking from '~/tracking';
+import { MAX_ITEM_LENGTH } from './data';
+
+export const TRACKING_CATEGORY = 'Language filters';
+export const TRACKING_LABEL_FILTERS = 'Filters';
+
+export const TRACKING_LABEL_MAX = 'Max Shown';
+export const TRACKING_LABEL_SHOW_MORE = 'Show More';
+export const TRACKING_LABEL_APPLY = 'Apply Filters';
+export const TRACKING_LABEL_RESET = 'Reset Filters';
+export const TRACKING_LABEL_ALL = 'All Filters';
+export const TRACKING_PROPERTY_MAX = `More than ${MAX_ITEM_LENGTH} filters to show`;
+
+export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click';
+export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show';
+
+// select is imported and used in checkbox_filter.vue
+export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select';
+
+export const trackShowMore = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, {
+ label: TRACKING_LABEL_ALL,
+ });
+
+export const trackShowHasOverMax = () =>
+ Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, {
+ label: TRACKING_LABEL_MAX,
+ property: TRACKING_PROPERTY_MAX,
+ });
+
+export const trackSubmitQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+
+export const trackResetQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: TRACKING_CATEGORY,
+ });
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index 0733dc72d2e..477ba37dab7 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -16,7 +16,7 @@ export default {
},
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
...mapGetters(['currentScope']),
ANY() {
return this.filterData.filters.ANY;
@@ -56,7 +56,7 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0" :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/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index 7d995f26684..24804baef44 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { HR_DEFAULT_CLASSES } from '../constants/index';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import { stateFilterData } from '../constants/state_filter_data';
import ConfidentialityFilter from './confidentiality_filter.vue';
@@ -15,7 +16,7 @@ export default {
ConfidentialityFilter,
},
computed: {
- ...mapState(['urlQuery', 'sidebarDirty']),
+ ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
...mapGetters(['currentScope']),
showReset() {
return this.urlQuery.state || this.urlQuery.confidential;
@@ -26,6 +27,9 @@ export default {
showStatusFilter() {
return Object.values(stateFilterData.scopes).includes(this.currentScope);
},
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
+ },
},
methods: {
...mapActions(['applyQuery', 'resetQuery']),
@@ -35,7 +39,7 @@ export default {
<template>
<form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" />
+ <hr v-if="!useNewNavigation" :class="hrClasses" />
<status-filter v-if="showStatusFilter" />
<confidentiality-filter v-if="showConfidentialityFilter" />
<div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5">
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 02a3870f499..fc41baee831 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -3,8 +3,8 @@ import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
-import { formatSearchResultCount } from '../../store/utils';
import { slugifyWithUnderscore } from '../../../lib/utils/text_utility';
export default {
@@ -22,15 +22,17 @@ export default {
...mapState(['navigation', 'urlQuery']),
},
created() {
- this.fetchSidebarCount();
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
},
methods: {
...mapActions(['fetchSidebarCount']),
- showFormatedCount(count) {
- return formatSearchResultCount(count);
+ showFormatedCount(countString) {
+ return formatSearchResultCount(countString);
},
- isCountOverLimit(count) {
- return count.includes('+');
+ isCountOverLimit(countString) {
+ return Boolean(addCountOverLimit(countString));
},
handleClick(scope) {
this.track('click_menu_item', { label: `vertical_navigation_${scope}` });
diff --git a/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
new file mode 100644
index 00000000000..86b7cc577a6
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+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',
+ i18n: {
+ countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'),
+ },
+ components: {
+ NavItem,
+ },
+ mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState(['navigation', 'urlQuery']),
+ ...mapGetters(['navigationItems']),
+ },
+ created() {
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
+ },
+ methods: {
+ ...mapActions(['fetchSidebarCount']),
+ },
+ NAV_LINK_DEFAULT_CLASSES,
+ NAV_LINK_COUNT_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <nav data-testid="search-filter" class="gl-py-2 gl-relative">
+ <ul class="gl-px-2 gl-list-style-none">
+ <nav-item v-for="item in navigationItems" :key="`menu-${item.title}`" :item="item" />
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index c3deabfcc26..44d6b537b7b 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { stateFilterData } from '../constants/state_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
stateFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <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 19b1ad0905b..9519154a571 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -4,7 +4,7 @@ export const SCOPE_BLOB = 'blobs';
export const LABEL_DEFAULT_CLASSES = [
'gl-display-flex',
'gl-flex-direction-row',
- 'gl-flex-wrap-nowrap',
+ 'gl-flex-nowrap',
'gl-text-gray-900',
];
export const NAV_LINK_DEFAULT_CLASSES = [
@@ -14,3 +14,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [
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 ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
+
+export const TRACKING_LABEL_CHECKBOX = 'Checkbox';
diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js
index 4357d6202df..78e03fcdeee 100644
--- a/app/assets/javascripts/search/sidebar/utils.js
+++ b/app/assets/javascripts/search/sidebar/utils.js
@@ -1,4 +1,4 @@
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
export const convertFiltersData = (rawBuckets) =>
rawBuckets.reduce(
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index da2bf4b602e..3d6ca2a6eee 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -4,7 +4,7 @@ import axios from '~/lib/utils/axios_utils';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
import {
@@ -140,8 +140,7 @@ export const fetchLanguageAggregation = ({ commit, state }) => {
commit(types.REQUEST_AGGREGATIONS);
return axios
.get(getAggregationsUrl())
- .then((result) => {
- const { data } = result;
+ .then(({ data }) => {
commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data));
})
.catch((e) => {
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index ba4fe85db9d..c8ee0a3f9d9 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,6 +1,6 @@
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -17,3 +17,15 @@ export const SIDEBAR_PARAMS = [
];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
+
+export const ICON_MAP = {
+ blobs: 'code',
+ issues: 'issues',
+ merge_requests: 'merge-request',
+ commits: 'commit',
+ notes: 'comments',
+ milestones: 'tag',
+ users: 'users',
+ projects: 'project',
+ wiki_blobs: 'overview',
+};
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 36d98233e28..135c9a3d67c 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,7 +1,8 @@
import { findKey, has } from 'lodash';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
-import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants';
export const frequentGroups = (state) => {
return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
@@ -26,3 +27,13 @@ export const queryLanguageFilters = (state) => state.query[languageFilterData.fi
export const currentUrlQueryHasLanguageFilters = (state) =>
has(state.urlQuery, languageFilterData.filterParam) &&
state.urlQuery[languageFilterData.filterParam]?.length > 0;
+
+export const navigationItems = (state) =>
+ Object.values(state.navigation).map((item) => ({
+ title: item.label,
+ icon: ICON_MAP[item.scope] || '',
+ link: item.link,
+ is_active: Boolean(item?.active),
+ pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '',
+ items: [],
+ }));
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index e20a43808cf..634f8f7a7fa 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 }) => ({
+export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({
actions,
getters,
mutations,
- state: createState({ query, navigation }),
+ state: createState({ query, navigation, useNewNavigation }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index d85a135bb4e..a62b6728819 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 }) => ({
+const createState = ({ query, navigation, useNewNavigation }) => ({
urlQuery: cloneDeep(query),
query,
groups: [],
@@ -14,6 +14,7 @@ const createState = ({ query, navigation }) => ({
},
sidebarDirty: false,
navigation,
+ useNewNavigation,
aggregations: {
error: false,
fetching: false,
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index 8e484e69646..2f02ef3475c 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -2,7 +2,7 @@ import { isEqual, orderBy } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { formatNumber } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
@@ -144,3 +144,7 @@ export const prepareSearchAggregations = (state, aggregationData) =>
return item;
});
+
+export const addCountOverLimit = (count = '') => {
+ return count.includes('+') ? '+' : '';
+};
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
deleted file mode 100644
index 94244eeb12e..00000000000
--- a/app/assets/javascripts/search_autocomplete.js
+++ /dev/null
@@ -1,520 +0,0 @@
-/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
-
-import $ from 'jquery';
-import { escape, throttle } from 'lodash';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, __, sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import axios from './lib/utils/axios_utils';
-import { spriteIcon } from './lib/utils/common_utils';
-import {
- isInGroupsPage,
- isInProjectPage,
- getGroupSlug,
- getProjectSlug,
-} from './search_autocomplete_utils';
-
-/**
- * Search input in top navigation bar.
- * On click, opens a dropdown
- * As the user types it filters the results
- * When the user clicks `x` button it cleans the input and closes the dropdown.
- */
-
-const KEYCODE = {
- ESCAPE: 27,
- BACKSPACE: 8,
- ENTER: 13,
- UP: 38,
- DOWN: 40,
-};
-
-function setSearchOptions() {
- const $projectOptionsDataEl = $('.js-search-project-options');
- const $groupOptionsDataEl = $('.js-search-group-options');
- const $dashboardOptionsDataEl = $('.js-search-dashboard-options');
-
- if ($projectOptionsDataEl.length) {
- gl.projectOptions = gl.projectOptions || {};
-
- const projectPath = $projectOptionsDataEl.data('projectPath');
-
- gl.projectOptions[projectPath] = {
- name: $projectOptionsDataEl.data('name'),
- issuesPath: $projectOptionsDataEl.data('issuesPath'),
- issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
- mrPath: $projectOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($groupOptionsDataEl.length) {
- gl.groupOptions = gl.groupOptions || {};
-
- const groupPath = $groupOptionsDataEl.data('groupPath');
-
- gl.groupOptions[groupPath] = {
- name: $groupOptionsDataEl.data('name'),
- issuesPath: $groupOptionsDataEl.data('issuesPath'),
- mrPath: $groupOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($dashboardOptionsDataEl.length) {
- gl.dashboardOptions = {
- name: s__('SearchAutocomplete|All GitLab'),
- issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
- mrPath: $dashboardOptionsDataEl.data('mrPath'),
- };
- }
-}
-
-export class SearchAutocomplete {
- constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
- setSearchOptions();
- this.bindEventContext();
- this.wrap = wrap || $('.search');
- this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
- this.projectId = projectId || this.optsEl.data('autocompleteProjectId') || '';
- this.projectRef = projectRef || this.optsEl.data('autocompleteProjectRef') || '';
- this.dropdown = this.wrap.find('.dropdown');
- this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
- this.dropdownMenu = this.dropdown.find('.dropdown-menu');
- this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.scopeInputEl = this.getElement('#scope');
- this.searchInput = this.getElement('.search-input');
- this.projectInputEl = this.getElement('#search_project_id');
- this.groupInputEl = this.getElement('#group_id');
- this.searchCodeInputEl = this.getElement('#search_code');
- this.repositoryInputEl = this.getElement('#repository_ref');
- this.clearInput = this.getElement('.js-clear-input');
- this.scrollFadeInitialized = false;
- this.saveOriginalState();
-
- // Only when user is logged in
- if (gon.current_user_id) {
- this.createAutocomplete();
- }
-
- this.bindEvents();
- this.dropdownToggle.dropdown();
- this.searchInput.addClass('js-autocomplete-disabled');
- }
-
- // Finds an element inside wrapper element
- bindEventContext() {
- this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
- this.onClearInputClick = this.onClearInputClick.bind(this);
- this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
- this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
- this.onSearchInputChange = this.onSearchInputChange.bind(this);
- this.setScrollFade = this.setScrollFade.bind(this);
- }
- getElement(selector) {
- return this.wrap.find(selector);
- }
-
- saveOriginalState() {
- return (this.originalState = this.serializeState());
- }
-
- createAutocomplete() {
- return initDeprecatedJQueryDropdown(this.searchInput, {
- filterInputBlur: false,
- filterable: true,
- filterRemote: true,
- highlight: true,
- icon: true,
- enterCallback: false,
- filterInput: 'input#search',
- search: {
- fields: ['text'],
- },
- id: this.getSearchText,
- data: this.getData.bind(this),
- selectable: true,
- clicked: this.onClick.bind(this),
- trackSuggestionClickedLabel: 'search_autocomplete_suggestion',
- });
- }
-
- getSearchText(selectedObject) {
- return selectedObject.id ? selectedObject.text : '';
- }
-
- getData(term, callback) {
- if (!term) {
- const contents = this.getCategoryContents();
- if (contents) {
- const deprecatedJQueryDropdownInstance = this.searchInput.data('deprecatedJQueryDropdown');
-
- if (deprecatedJQueryDropdownInstance) {
- deprecatedJQueryDropdownInstance.filter.options.callback(contents);
- }
- this.enableAutocomplete();
- }
- return;
- }
-
- // Prevent multiple ajax calls
- if (this.loadingSuggestions) {
- return;
- }
-
- this.loadingSuggestions = true;
-
- return axios
- .get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term,
- },
- })
- .then((response) => {
- const options = this.scopedSearchOptions(term);
-
- // List results
- let lastCategory = null;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- options.push({ type: 'separator' });
- options.push({
- type: 'header',
- content: suggestion.category,
- });
- lastCategory = suggestion.category;
- }
-
- // Add the suggestion
- options.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- icon: this.getAvatar(suggestion),
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
-
- callback(options);
-
- this.loadingSuggestions = false;
- this.highlightFirstRow();
- this.setScrollFade();
- })
- .catch(() => {
- this.loadingSuggestions = false;
- });
- }
-
- getCategoryContents() {
- const userName = gon.current_username;
- const { projectOptions, groupOptions, dashboardOptions } = gl;
-
- // Get options
- let options;
- if (isInProjectPage() && projectOptions) {
- options = projectOptions[getProjectSlug()];
- } else if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
- }
-
- const { issuesPath, mrPath, name, issuesDisabled } = options;
- const baseItems = [];
-
- if (name) {
- baseItems.push({
- type: 'header',
- content: `${name}`,
- });
- }
-
- const issueItems = [
- {
- text: s__('SearchAutocomplete|Issues assigned to me'),
- url: `${issuesPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Issues I've created"),
- url: `${issuesPath}/?author_username=${userName}`,
- },
- ];
- const mergeRequestItems = [
- {
- text: s__('SearchAutocomplete|Merge requests assigned to me'),
- url: `${mrPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"),
- url: `${mrPath}/?reviewer_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests I've created"),
- url: `${mrPath}/?author_username=${userName}`,
- },
- ];
-
- let items;
- if (issuesDisabled) {
- items = baseItems.concat(mergeRequestItems);
- } else {
- items = baseItems.concat(...issueItems, ...mergeRequestItems);
- }
- return items;
- }
-
- // Add option to proceed with the search for each
- // scope that is currently available, namely:
- //
- // - Search in this project
- // - Search in this group (or project's group)
- // - Search in all GitLab
- scopedSearchOptions(term) {
- const icon = spriteIcon('search', 's16 inline-search-icon');
- const projectId = this.projectInputEl.val();
- const groupId = this.groupInputEl.val();
- const options = [];
-
- if (projectId) {
- const projectOptions = gl.projectOptions[getProjectSlug()];
- const url = groupId
- ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar`
- : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`;
-
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in project %{projectName}`),
- {
- projectName: `<i>${projectOptions.name}</i>`,
- },
- false,
- ),
- url,
- });
- }
-
- if (groupId) {
- const groupOptions = gl.groupOptions[getGroupSlug()];
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in group %{groupName}`),
- {
- groupName: `<i>${groupOptions.name}</i>`,
- },
- false,
- ),
- url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`,
- });
- }
-
- options.push({
- icon,
- text: term,
- template: s__('SearchAutocomplete|in all GitLab'),
- url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`,
- });
-
- return options;
- }
-
- serializeState() {
- return {
- // Search Criteria
- search_project_id: this.projectInputEl.val(),
- group_id: this.groupInputEl.val(),
- search_code: this.searchCodeInputEl.val(),
- repository_ref: this.repositoryInputEl.val(),
- scope: this.scopeInputEl.val(),
- };
- }
-
- bindEvents() {
- this.searchInput.on('input', this.onSearchInputChange);
- this.searchInput.on('keyup', this.onSearchInputKeyUp);
- this.searchInput.on('focus', this.onSearchInputFocus);
- this.searchInput.on('blur', this.onSearchInputBlur);
- this.clearInput.on('click', this.onClearInputClick);
- this.dropdownContent.on(
- 'scroll',
- throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- );
-
- this.searchInput.on('click', (e) => {
- e.stopPropagation();
- });
- }
-
- enableAutocomplete() {
- this.setScrollFade();
-
- // No need to enable anything if user is not logged in
- if (!gon.current_user_id) {
- return;
- }
-
- // If the dropdown is closed, we'll open it
- if (!this.dropdown.hasClass('show')) {
- this.loadingSuggestions = false;
- this.dropdownToggle.dropdown('toggle');
-
- const trackEvent = 'click_search_bar';
- const trackCategory = undefined; // will be default set in event method
-
- Tracking.event(trackCategory, trackEvent, {
- label: 'main_navigation',
- property: 'navigation',
- });
-
- return this.searchInput.removeClass('js-autocomplete-disabled');
- }
- }
-
- onSearchInputChange() {
- this.enableAutocomplete();
- }
-
- onSearchInputKeyUp(e) {
- switch (e.keyCode) {
- case KEYCODE.ESCAPE:
- this.restoreOriginalState();
- break;
- case KEYCODE.ENTER:
- this.disableAutocomplete();
- break;
- default:
- }
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- }
-
- onSearchInputFocus() {
- this.isFocused = true;
- this.wrap.addClass('search-active');
- if (this.getValue() === '') {
- return this.getData();
- }
- }
-
- getValue() {
- return this.searchInput.val();
- }
-
- onClearInputClick(e) {
- e.preventDefault();
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- return this.searchInput.val('').focus();
- }
-
- onSearchInputBlur() {
- this.isFocused = false;
- this.wrap.removeClass('search-active');
- // If input is blank then restore state
- if (this.searchInput.val() === '') {
- this.restoreOriginalState();
- }
- this.dropdownMenu.removeClass('show');
- }
-
- restoreOriginalState() {
- const inputs = Object.keys(this.originalState);
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- this.getElement(`#${input}`).val(this.originalState[input]);
- }
- }
-
- resetSearchState() {
- const inputs = Object.keys(this.originalState);
- const results = [];
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- results.push(this.getElement(`#${input}`).val(''));
- }
- return results;
- }
-
- disableAutocomplete() {
- if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
- this.searchInput.addClass('js-autocomplete-disabled');
- this.dropdownToggle.dropdown('toggle');
- this.restoreMenu();
- }
- }
-
- restoreMenu() {
- const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
- return this.dropdownContent.html(html);
- }
-
- onClick(item, $el, e) {
- if (window.location.pathname.indexOf(item.url) !== -1) {
- if (!e.metaKey) e.preventDefault();
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- }
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- }
- $el.removeClass('is-active');
- this.disableAutocomplete();
- return this.searchInput.val('').focus();
- }
- }
-
- highlightFirstRow() {
- this.searchInput.data('deprecatedJQueryDropdown').highlightRowAtIndex(null, 0);
- }
-
- getAvatar(item) {
- if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) {
- return false;
- }
-
- const { label, id } = item;
- const avatarUrl = item.avatar_url;
- const avatar = avatarUrl
- ? `<img class="search-item-avatar" src="${avatarUrl}" />`
- : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
- escape(label),
- )}</div>`;
-
- return avatar;
- }
-
- isScrolledUp() {
- const el = this.dropdownContent[0];
- const currentPosition = this.contentClientHeight + el.scrollTop;
-
- return currentPosition < this.maxPosition;
- }
-
- initScrollFade() {
- const el = this.dropdownContent[0];
- this.scrollFadeInitialized = true;
-
- this.contentClientHeight = el.clientHeight;
- this.maxPosition = el.scrollHeight;
- this.dropdownMenu.addClass('dropdown-content-faded-mask');
- }
-
- setScrollFade() {
- this.initScrollFade();
-
- this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
- }
-}
-
-export default function initSearchAutocomplete(opts) {
- return new SearchAutocomplete(opts);
-}
diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js
deleted file mode 100644
index a9a0f941e93..00000000000
--- a/app/assets/javascripts/search_autocomplete_utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { getPagePath } from './lib/utils/common_utils';
-
-export const isInGroupsPage = () => getPagePath() === 'groups';
-
-export const isInProjectPage = () => getPagePath() === 'projects';
-
-export const getProjectSlug = () => {
- if (isInProjectPage()) {
- return document?.body?.dataset?.project;
- }
- return null;
-};
-
-export const getGroupSlug = () => {
- if (isInProjectPage() || isInGroupsPage()) {
- return document?.body?.dataset?.group;
- }
- return null;
-};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index ccfaa678201..e96f71981e5 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -1,7 +1,6 @@
<script>
import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { parseErrorMessage } from '~/lib/utils/error_message';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
@@ -34,9 +33,6 @@ export const i18n = {
'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.',
),
securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'),
- genericErrorText: s__(
- `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
- ),
};
export default {
@@ -128,9 +124,8 @@ export default {
dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
- onError(error) {
- const { message, userFacing } = parseErrorMessage(error);
- this.errorMessage = userFacing ? message : i18n.genericErrorText;
+ onError(message) {
+ this.errorMessage = message;
},
dismissAlert() {
this.errorMessage = '';
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 6beb6cd4d34..1d5ff5eb16f 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -6,6 +6,7 @@ import {
REPORT_TYPE_SAST_IAC,
REPORT_TYPE_DAST,
REPORT_TYPE_DAST_PROFILES,
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
@@ -67,6 +68,30 @@ export const DAST_PROFILES_DESCRIPTION = s__(
);
export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles');
+export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature');
+export const BAS_BADGE_TOOLTIP = s__(
+ 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.',
+);
+export const BAS_DESCRIPTION = s__(
+ 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.',
+);
+export const BAS_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+);
+export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)');
+export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS');
+
+export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__(
+ 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.',
+);
+export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+ { anchor: 'extend-dynamic-application-security-testing-dast' },
+);
+export const BAS_DAST_FEATURE_FLAG_NAME = s__(
+ 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)',
+);
+
export const SECRET_DETECTION_NAME = __('Secret Detection');
export const SECRET_DETECTION_DESCRIPTION = __(
'Analyze your source code and git history for secrets.',
@@ -142,6 +167,7 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ BAS: BAS_SHORT_NAME,
GENERIC: s__('ciReport|Manually added'),
};
@@ -223,6 +249,25 @@ export const securityFeatures = [
configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
},
},
+ {
+ anchor: 'bas',
+ badge: {
+ alwaysDisplay: true,
+ text: BAS_BADGE_TEXT,
+ tooltipText: BAS_BADGE_TOOLTIP,
+ variant: 'info',
+ },
+ description: BAS_DESCRIPTION,
+ name: BAS_NAME,
+ helpPath: BAS_HELP_PATH,
+ secondary: {
+ configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH,
+ description: BAS_DAST_FEATURE_FLAG_DESCRIPTION,
+ name: BAS_DAST_FEATURE_FLAG_NAME,
+ },
+ shortName: BAS_SHORT_NAME,
+ type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ },
];
export const complianceFeatures = [
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 19b412d66ca..d1b705fe2fc 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -1,7 +1,10 @@
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
-import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants';
+import {
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ REPORT_TYPE_SAST_IAC,
+} from '~/vue_shared/security_reports/constants';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import FeatureCardBadge from './feature_card_badge.vue';
@@ -68,8 +71,7 @@ export default {
};
},
hasSecondary() {
- const { name, description, configurationText } = this.feature.secondary ?? {};
- return Boolean(name && description && configurationText);
+ return Boolean(this.feature.secondary);
},
// This condition is a temporary hack to not display any wrong information
// until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307.
@@ -78,7 +80,17 @@ export default {
return this.feature.type !== REPORT_TYPE_SAST_IAC;
},
hasBadge() {
- return Boolean(this.available && this.feature.badge?.text);
+ const shouldDisplay = this.available || this.feature.badge?.alwaysDisplay;
+ return Boolean(shouldDisplay && this.feature.badge?.text);
+ },
+ hasEnabledStatus() {
+ return (
+ this.isNotSastIACTemporaryHack &&
+ this.feature.type !== REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION
+ );
+ },
+ showSecondaryConfigurationHelpPath() {
+ return Boolean(this.available && this.feature.secondary?.configurationHelpPath);
},
},
methods: {
@@ -118,19 +130,25 @@ export default {
:badge-href="feature.badge.badgeHref"
/>
- <template v-if="enabled">
- <span>
- <gl-icon name="check-circle-filled" />
- <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
- </span>
- </template>
-
- <template v-else-if="available">
- <span>{{ $options.i18n.notEnabled }}</span>
+ <template v-if="hasEnabledStatus">
+ <template v-if="enabled">
+ <span>
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </span>
+ </template>
+
+ <template v-else-if="available">
+ <span>{{ $options.i18n.notEnabled }}</span>
+ </template>
+
+ <template v-else>
+ {{ $options.i18n.availableWith }}
+ </template>
</template>
- <template v-else>
- {{ $options.i18n.availableWith }}
+ <template v-else-if="!available">
+ <span>{{ $options.i18n.availableWith }}</span>
</template>
</div>
</div>
@@ -186,6 +204,16 @@ export default {
>
{{ feature.secondary.configurationText }}
</gl-button>
+
+ <gl-button
+ v-else-if="showSecondaryConfigurationHelpPath"
+ icon="external-link"
+ :href="feature.secondary.configurationHelpPath"
+ category="secondary"
+ class="gl-mt-5"
+ >
+ {{ $options.i18n.configurationGuide }}
+ </gl-button>
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index 8b40b48b54a..c61c02c8b3a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,7 +1,7 @@
<script>
-import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
-import { s__, sprintf } from '~/locale';
+import { __ } from '~/locale';
const AVAILABILITY_STATUS = {
NOT_SET: 'NOT_SET',
@@ -11,6 +11,7 @@ const AVAILABILITY_STATUS = {
export default {
components: {
GlAvatarLabeled,
+ GlBadge,
GlIcon,
},
props: {
@@ -25,30 +26,23 @@ export default {
},
},
computed: {
- userLabel() {
- const { name, status } = this.user;
- if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) {
- return name;
- }
- return sprintf(
- s__('UserAvailability|%{author} (Busy)'),
- {
- author: name,
- },
- false,
- );
+ isBusy() {
+ return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY;
},
hasCannotMergeIcon() {
return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge;
},
},
+ i18n: {
+ busy: __('Busy'),
+ },
};
</script>
<template>
<gl-avatar-labeled
:size="32"
- :label="userLabel"
+ :label="user.name"
:sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url"
class="gl-align-items-center gl-relative sidebar-participant"
@@ -61,6 +55,9 @@ export default {
class="merge-icon"
:size="12"
/>
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ $options.i18n.busy }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
index bed84dc5706..72084fdafb1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
@@ -1,10 +1,11 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlBadge, GlSprintf } from '@gitlab/ui';
import { isUserBusy } from '~/set_status_modal/utils';
export default {
name: 'UserNameWithStatus',
components: {
+ GlBadge,
GlSprintf,
},
props: {
@@ -40,17 +41,17 @@ export default {
</script>
<template>
<span :class="containerClasses">
- <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')">
+ <gl-sprintf :message="s__('UserAvailability|%{author}%{badgeStart}Busy%{badgeEnd}')">
<template #author
- >{{ name }}
- <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ ><span>{{ name }}</span
+ ><span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-ml-1"
>({{ pronouns }})</span
></template
>
- <template #span="{ content }"
- ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{
- content
- }}</span>
+ <template #badge="{ content }">
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ content }}
+ </gl-badge>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 190b8c1de62..5a9545f3460 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -106,6 +106,19 @@ export default {
),
});
},
+ subscribeToMore: {
+ document() {
+ return this.dateQueries[this.issuableType].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.skipIssueDueDateSubscription;
+ },
+ },
},
},
computed: {
@@ -163,6 +176,12 @@ export default {
dataTestId() {
return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date';
},
+ issuableId() {
+ return this.issuable.id;
+ },
+ skipIssueDueDateSubscription() {
+ return this.issuableType !== TYPE_ISSUE || !this.issuableId || this.isLoading;
+ },
},
methods: {
epicDatePopoverEl() {
@@ -302,6 +321,7 @@ export default {
v-if="!isLoading"
ref="datePicker"
class="gl-relative"
+ :value="parsedDate"
:min-date="minDate"
:max-date="maxDate"
:default-date="parsedDate"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
index 227d85d952b..8535398decf 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
@@ -78,6 +78,7 @@ export default {
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
+ data-testid="label-title"
/>
</div>
<div class="dropdown-content px-2">
@@ -113,6 +114,7 @@ export default {
category="primary"
variant="confirm"
class="float-left d-flex align-items-center"
+ data-testid="create-click"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
index 852ef0c6283..881d84a7d6e 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
@@ -1,5 +1,6 @@
export const SCOPED_LABEL_DELIMITER = '::';
export const DEBOUNCE_DROPDOWN_DELAY = 200;
+export const DEFAULT_LABEL_COLOR = '#6699cc';
export const DropdownVariant = {
Sidebar: 'sidebar',
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
index 1174ec3f01e..30eeb0fbe31 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
@@ -13,6 +13,7 @@ import { WORKSPACE_GROUP } from '~/issues/constants';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
+import { DEFAULT_LABEL_COLOR } from './constants';
const errorMessage = __('Error creating label.');
@@ -44,11 +45,16 @@ export default {
type: String,
required: true,
},
+ searchKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- labelTitle: '',
- selectedColor: '',
+ labelTitle: this.searchKey,
+ selectedColor: DEFAULT_LABEL_COLOR,
labelCreateInProgress: false,
error: undefined,
};
diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
index 76c47305369..581537264db 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
@@ -26,10 +26,11 @@ export default {
},
},
methods: {
- moveIssue(targetProject) {
+ async moveIssue(targetProject) {
this.moveInProgress = true;
- return this.$apollo
- .mutate({
+
+ try {
+ const { data } = await this.$apollo.mutate({
mutation: moveIssueMutation,
variables: {
moveIssueInput: {
@@ -38,24 +39,25 @@ export default {
targetProjectPath: targetProject.full_path,
},
},
- })
- .then(({ data = {} }) => {
- if (!data.issueMove) return;
+ });
+
+ if (!data.issueMove) return;
+
+ const { errors } = data.issueMove;
+ if (errors?.length > 0) {
+ throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
+ }
- const { errors } = data.issueMove;
- if (errors?.length > 0) {
- throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
- }
- visitUrl(data.issueMove?.issue.webUrl);
- })
- .catch((error) => {
- this.moveInProgress = false;
- createAlert({
- message: this.$options.i18n.moveErrorMessage,
- captureError: true,
- error,
- });
+ visitUrl(data.issueMove?.issue.webUrl);
+ } catch (error) {
+ createAlert({
+ message: this.$options.i18n.moveErrorMessage,
+ captureError: true,
+ error,
});
+ } finally {
+ this.moveInProgress = false;
+ }
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index a9d102eb303..bbd3cda0ad3 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -69,7 +69,7 @@ export default {
},
participantLabel() {
return sprintf(
- n__('%{count} participant', '%{count} participants', this.participants.length),
+ n__('%{count} Participant', '%{count} Participants', this.participants.length),
{ count: this.loading ? '' : this.participantCount },
);
},
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index a3710d9534e..99f9d5e872c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -6,6 +6,7 @@ import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
+const JUST_APPROVED = 'approved';
export default {
i18n: {
@@ -42,7 +43,7 @@ export default {
},
watch: {
users: {
- handler(users) {
+ handler(users, previousUsers) {
this.loadingStates = users.reduce(
(acc, user) => ({
...acc,
@@ -50,14 +51,41 @@ export default {
}),
this.loadingStates,
);
+ if (previousUsers) {
+ users.forEach((user) => {
+ const userPreviousState = previousUsers.find(({ id }) => id === user.id);
+ if (
+ userPreviousState &&
+ user.mergeRequestInteraction.approved &&
+ !userPreviousState.mergeRequestInteraction.approved
+ ) {
+ this.showApprovalAnimation(user.id);
+ }
+ });
+ }
},
immediate: true,
},
},
methods: {
+ showApprovalAnimation(userId) {
+ this.loadingStates[userId] = JUST_APPROVED;
+
+ setTimeout(() => {
+ this.loadingStates[userId] = null;
+ }, 1500);
+ },
+ approveAnimation(userId) {
+ return {
+ 'merge-request-approved-icon': this.loadingStates[userId] === JUST_APPROVED,
+ };
+ },
approvedByTooltipTitle(user) {
return sprintf(s__('MergeRequest|Approved by @%{username}'), user);
},
+ reviewedButNotApprovedTooltip(user) {
+ return sprintf(s__('MergeRequest|Reviewed by @%{username} but not yet approved'), user);
+ },
toggleShowLess() {
this.showLess = !this.showLess;
},
@@ -105,35 +133,38 @@ export default {
{{ user.name }}
</div>
</reviewer-avatar-link>
- <gl-icon
- v-if="user.mergeRequestInteraction.approved"
- v-gl-tooltip.left
- :size="16"
- :title="approvedByTooltipTitle(user)"
- name="status-success"
- class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
- data-testid="re-approved"
- />
- <gl-icon
- v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
- :size="24"
- name="check"
- class="float-right gl-py-2 gl-mr-2 gl-text-green-500"
- data-testid="re-request-success"
- />
<gl-button
- v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
+ v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
- class="float-right gl-text-gray-500!"
+ class="float-right gl-text-gray-500! gl-mr-2"
size="small"
icon="redo"
variant="link"
data-testid="re-request-button"
@click="reRequestReview(user.id)"
/>
+ <gl-icon
+ v-if="user.mergeRequestInteraction.approved"
+ v-gl-tooltip.left
+ :size="16"
+ :title="approvedByTooltipTitle(user)"
+ name="status-success"
+ class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
+ :class="approveAnimation(user.id)"
+ data-testid="approved"
+ />
+ <gl-icon
+ v-else-if="user.mergeRequestInteraction.reviewed"
+ v-gl-tooltip.left
+ :size="16"
+ :title="reviewedButNotApprovedTooltip(user)"
+ name="dotted-circle"
+ class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0"
+ data-testid="reviewed-not-approved"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 19e72da65f2..4721c6fee61 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -81,7 +81,7 @@ export default {
},
},
apollo: {
- currentAttribute: {
+ issuable: {
query() {
const { current } = this.issuableAttributeQuery;
const { query } = current[this.issuableType];
@@ -95,11 +95,12 @@ export default {
};
},
update(data) {
+ return data.workspace?.issuable || {};
+ },
+ result({ data }) {
if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) {
this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic;
}
-
- return data?.workspace?.issuable.attribute;
},
error(error) {
createAlert({
@@ -108,13 +109,26 @@ export default {
error,
});
},
+ subscribeToMore: {
+ document() {
+ return issuableAttributesQueries[this.issuableAttribute].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.shouldSkipRealTimeEpicLinkUpdates;
+ },
+ },
},
},
data() {
return {
updating: false,
selectedTitle: null,
- currentAttribute: null,
+ issuable: {},
hasCurrentAttribute: false,
editConfirmation: false,
tracking: {
@@ -125,6 +139,12 @@ export default {
};
},
computed: {
+ currentAttribute() {
+ return this.issuable.attribute;
+ },
+ issuableId() {
+ return this.issuable.id;
+ },
issuableAttributeQuery() {
return this.issuableAttributesQueries[this.issuableAttribute];
},
@@ -135,7 +155,7 @@ export default {
return this.currentAttribute?.webUrl;
},
loading() {
- return this.$apollo.queries.currentAttribute.loading;
+ return this.$apollo.queries.issuable.loading;
},
attributeTypeTitle() {
return this.widgetTitleText[this.issuableAttribute];
@@ -170,6 +190,9 @@ export default {
? !this.editConfirmation
: false;
},
+ shouldSkipRealTimeEpicLinkUpdates() {
+ return !this.issuableId || this.issuableAttribute !== IssuableAttributeType.Epic;
+ },
},
methods: {
updateAttribute({ id }) {
diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
index 6dacf4e10d3..ba0bf783315 100644
--- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
@@ -32,9 +32,22 @@ export default {
return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
},
},
+ watch: {
+ collapsed(value) {
+ this.updateLayout(value);
+ },
+ },
+ mounted() {
+ this.page = document.querySelector('.layout-page');
+ },
methods: {
toggle() {
this.$emit('toggle');
+ this.updateLayout();
+ },
+ updateLayout(collapsed) {
+ this.page?.classList.remove(collapsed ? 'right-sidebar-expanded' : 'right-sidebar-collapsed');
+ this.page?.classList.add(collapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded');
},
},
};
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 7bca83c4142..0f82182c6e2 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -13,6 +13,7 @@ import {
WORKSPACE_PROJECT,
} from '~/issues/constants';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql';
import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql';
import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
@@ -218,6 +219,7 @@ export const dueDateQueries = {
[TYPE_ISSUE]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
+ subscription: issuableDatesUpdatedSubscription,
},
[TYPE_EPIC]: {
query: epicDueDateQuery,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 99c3fdf82d4..2828b9fbf1a 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -148,7 +148,7 @@ function mountSidebarAssigneesWidget() {
name: 'SidebarAssigneesRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
directlyInviteMembers: Object.prototype.hasOwnProperty.call(
el.dataset,
'directlyInviteMembers',
@@ -162,7 +162,7 @@ function mountSidebarAssigneesWidget() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1,
- editable,
+ editable: parseBoolean(editable),
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -415,7 +415,7 @@ function mountSidebarDueDateWidget() {
name: 'SidebarDueDateWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarDueDateWidget, {
@@ -476,7 +476,7 @@ function mountIssuableLockForm(store) {
render: (createElement) =>
createElement(IssuableLockForm, {
props: {
- isEditable: editable,
+ isEditable: parseBoolean(editable),
},
}),
});
@@ -523,7 +523,7 @@ function mountSidebarSubscriptionsWidget() {
name: 'SidebarSubscriptionsWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSubscriptionsWidget, {
@@ -587,7 +587,7 @@ function mountSidebarSeverityWidget() {
name: 'SidebarSeverityWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSeverityWidget, {
@@ -645,7 +645,7 @@ function mountCopyEmailToClipboard() {
});
}
-export function mountMoveIssuesButton() {
+export async function mountMoveIssuesButton() {
const el = document.querySelector('.js-move-issues');
if (!el) {
@@ -658,7 +658,7 @@ export function mountMoveIssuesButton() {
el,
name: 'MoveIssuesRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render: (createElement) =>
createElement(MoveIssuesButton, {
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index baf906bb96c..ea3b3633ea7 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default class SidebarStore {
constructor(options) {
if (!SidebarStore.singleton) {
@@ -12,7 +14,7 @@ export default class SidebarStore {
const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
- this.editable = editable;
+ this.editable = parseBoolean(editable);
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
index 6b90fb80abf..a61b4e4f066 100644
--- a/app/assets/javascripts/sidebar/utils.js
+++ b/app/assets/javascripts/sidebar/utils.js
@@ -12,7 +12,7 @@ export const updateGlobalTodoCount = (additionalTodoCount) => {
if (countContainer === null) return;
- const currentCount = parseInt(countContainer.innerText, 10);
+ const currentCount = parseInt(countContainer.innerText, 10) || 0;
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b613e356a7a..bab167bb7e4 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
import $ from 'jquery';
import { createAlert } from '~/alert';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -66,7 +64,7 @@ export default class SingleFileDiff {
} else {
this.$chevronDownIcon.removeClass('gl-display-none');
this.$chevronRightIcon.addClass('gl-display-none');
- return this.getContentHTML(cb);
+ return this.getContentHTML(cb); // eslint-disable-line consistent-return
}
}
diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index e6aa3be0371..24dd978585c 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -42,7 +42,7 @@ export default {
<label>
{{ __('Visibility level') }}
<gl-link v-if="helpLink" :href="helpLink" target="_blank"
- ><gl-icon :size="12" name="question"
+ ><gl-icon :size="12" name="question-o"
/></gl-link>
</label>
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index b3d4ecdda47..fa9da6cef9d 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -1,10 +1,9 @@
<script>
import * as Sentry from '@sentry/browser';
-import { GlSearchBoxByType } from '@gitlab/ui';
+import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
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 { contextSwitcherItems } from '../mock_data';
import { trackContextAccess, formatContextSwitcherItems } from '../utils';
import NavItem from './nav_item.vue';
import ProjectsList from './projects_list.vue';
@@ -14,12 +13,13 @@ export default {
i18n: {
contextNavigation: s__('Navigation|Context navigation'),
switchTo: s__('Navigation|Switch to...'),
- searchPlaceholder: s__('Navigation|Search for projects or groups'),
+ searchPlaceholder: s__('Navigation|Search your projects or groups'),
+ searchingLabel: s__('Navigation|Retrieving search results'),
+ searchError: s__('Navigation|There was an error fetching search results.'),
},
apollo: {
groupsAndProjects: {
query: searchUserProjectsAndGroups,
- debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
manual: true,
variables() {
return {
@@ -28,6 +28,7 @@ export default {
};
},
result(response) {
+ this.hasError = false;
try {
const {
data: {
@@ -41,11 +42,11 @@ export default {
this.projects = formatContextSwitcherItems(projects);
this.groups = formatContextSwitcherItems(groups);
} catch (e) {
- Sentry.captureException(e);
+ this.handleError(e);
}
},
error(e) {
- Sentry.captureException(e);
+ this.handleError(e);
},
skip() {
return !this.searchString;
@@ -54,11 +55,17 @@ export default {
},
components: {
GlSearchBoxByType,
+ GlLoadingIcon,
+ GlAlert,
NavItem,
ProjectsList,
GroupsList,
},
props: {
+ persistentLinks: {
+ type: Array,
+ required: true,
+ },
username: {
type: String,
required: true,
@@ -82,19 +89,36 @@ export default {
searchString: '',
projects: [],
groups: [],
+ hasError: false,
};
},
computed: {
isSearch() {
return Boolean(this.searchString);
},
+ isSearching() {
+ return this.$apollo.queries.groupsAndProjects.loading;
+ },
},
- contextSwitcherItems,
created() {
if (this.currentContext.namespace) {
trackContextAccess(this.username, this.currentContext);
}
},
+ methods: {
+ /**
+ * This needs to be exposed publicly so that we can auto-focus the search input when the parent
+ * GlCollapse is shown.
+ */
+ focusInput() {
+ this.$refs['search-box'].focusInput();
+ },
+ handleError(e) {
+ Sentry.captureException(e);
+ this.hasError = true;
+ },
+ },
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
};
</script>
@@ -102,21 +126,36 @@ export default {
<div>
<div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white">
<gl-search-box-by-type
+ ref="search-box"
v-model="searchString"
class="context-switcher-search-box"
:placeholder="$options.i18n.searchPlaceholder"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
borderless
/>
</div>
- <nav :aria-label="$options.i18n.contextNavigation">
+ <gl-loading-icon
+ v-if="isSearching"
+ class="gl-mt-5"
+ size="md"
+ :label="$options.i18n.searchingLabel"
+ />
+ <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2">
+ {{ $options.i18n.searchError }}
+ </gl-alert>
+ <nav v-else :aria-label="$options.i18n.contextNavigation">
<ul class="gl-p-0 gl-list-style-none">
<li v-if="!isSearch">
<div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
{{ $options.i18n.switchTo }}
</div>
<ul :aria-label="$options.i18n.switchTo" class="gl-p-0">
- <nav-item :item="$options.contextSwitcherItems.yourWork" />
- <nav-item :item="$options.contextSwitcherItems.explore" />
+ <nav-item
+ v-for="item in persistentLinks"
+ :key="item.link"
+ :item="item"
+ :link-classes="{ [item.link_classes]: item.link_classes }"
+ />
</ul>
</li>
<projects-list
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 e0b6870872c..e56ef9e410b 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -38,13 +38,13 @@ export default {
<button
v-collapse-toggle.context-switcher
type="button"
- class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-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"
+ 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"
>
<span
v-if="context.icon"
class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4"
>
- <gl-icon :name="context.icon" :size="16" />
+ <gl-icon class="gl-text-gray-700" :name="context.icon" :size="16" />
</span>
<gl-avatar
v-else
@@ -55,11 +55,11 @@ export default {
:src="context.avatar"
class="gl-mr-3 gl-ml-4"
/>
- <div class="gl-overflow-auto">
+ <div class="gl-overflow-auto gl-text-gray-900">
<gl-truncate :text="context.title" />
</div>
<span class="gl-flex-grow-1 gl-text-right gl-mr-4">
- <gl-icon :name="collapseIcon" />
+ <gl-icon class="gl-text-gray-400" :name="collapseIcon" />
</span>
</button>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index e79b609545e..98417b7cd25 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -7,7 +7,7 @@ export default {
},
props: {
count: {
- type: Number,
+ type: [Number, String],
required: true,
},
href: {
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index d3bb31a69fa..4cff4642cf7 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -1,6 +1,10 @@
<script>
import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
import { __ } from '~/locale';
+import { DROPDOWN_Y_OFFSET } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -147;
export default {
components: {
@@ -16,7 +20,22 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ dropdownOpen: false,
+ };
+ },
toggleId: 'create-menu-toggle',
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
+ },
};
</script>
@@ -30,9 +49,17 @@ export default {
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
+ :popper-options="$options.popperOptions"
data-qa-selector="new_menu_toggle"
+ @shown="dropdownOpen = true"
+ @hidden="dropdownOpen = false"
/>
- <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
+ <gl-tooltip
+ v-if="!dropdownOpen"
+ :target="`#${$options.toggleId}`"
+ placement="bottom"
+ container="#super-sidebar"
+ >
{{ $options.i18n.createNew }}
</gl-tooltip>
</div>
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 5269c7f8d5e..56143a29f52 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,20 @@
<script>
+import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import { s__ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
import ItemsList from './items_list.vue';
export default {
components: {
+ GlIcon,
+ GlButton,
ItemsList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
title: {
type: String,
@@ -29,12 +36,16 @@ export default {
data() {
return {
cachedFrequentItems: [],
+ isItemsListEditable: false,
};
},
computed: {
isEmpty() {
return !this.cachedFrequentItems.length;
},
+ allowItemsEditing() {
+ return !this.isEmpty && AccessorUtilities.canUseLocalStorage();
+ },
},
created() {
this.getItemsFromLocalStorage();
@@ -52,6 +63,27 @@ export default {
Sentry.captureException(e);
}
},
+ toggleItemsListEditablity() {
+ this.isItemsListEditable = !this.isItemsListEditable;
+ },
+ handleItemRemove(item) {
+ try {
+ // Remove item from local storage
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
+ localStorage.setItem(
+ this.storageKey,
+ JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)),
+ );
+
+ // Update the list
+ this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ },
+ i18n: {
+ frequentItemsEditToggle: s__('Navigation|Toggle edit mode'),
},
};
</script>
@@ -61,14 +93,32 @@ export default {
<div
data-testid="list-title"
aria-hidden="true"
- class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
>
- {{ title }}
+ <span class="gl-flex-grow-1">{{ title }}</span>
+ <gl-button
+ v-if="allowItemsEditing"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.frequentItemsEditToggle"
+ :title="$options.i18n.frequentItemsEditToggle"
+ :class="{ 'gl-bg-gray-100!': isItemsListEditable }"
+ class="gl-p-2!"
+ @click="toggleItemsListEditablity"
+ >
+ <gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" />
+ </gl-button>
</div>
<div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
{{ pristineText }}
</div>
- <items-list :aria-label="title" :items="cachedFrequentItems">
+ <items-list
+ :aria-label="title"
+ :items="cachedFrequentItems"
+ :editable="isItemsListEditable"
+ @remove-item="handleItemRemove"
+ >
<template #view-all-items>
<slot name="view-all-items"></slot>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
index 6798607b954..e8a54b0515e 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -6,73 +6,64 @@ import {
GlToken,
GlTooltipDirective,
GlResizeObserverDirective,
+ GlModal,
} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import { debounce } from 'lodash';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { debounce, clamp } from 'lodash';
import { truncate } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys';
import {
+ MIN_SEARCH_TERM,
SEARCH_GITLAB,
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
SEARCH_DESCRIBED_BY_DEFAULT,
SEARCH_DESCRIBED_BY_UPDATED,
SEARCH_RESULTS_LOADING,
SEARCH_RESULTS_SCOPE,
- KBD_HELP,
} from '~/vue_shared/global_search/constants';
import {
- FIRST_DROPDOWN_INDEX,
- SEARCH_BOX_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
IS_SEARCHING,
- IS_FOCUSED,
- IS_NOT_FOCUSED,
+ SEARCH_MODAL_ID,
+ SEARCH_INPUT_SELECTOR,
+ SEARCH_RESULTS_ITEM_SELECTOR,
} from '../constants';
-import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue';
-import HeaderSearchDefaultItems from './global_search_default_items.vue';
-import HeaderSearchScopedItems from './global_search_scoped_items.vue';
+import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
+import GlobalSearchDefaultItems from './global_search_default_items.vue';
+import GlobalSearchScopedItems from './global_search_scoped_items.vue';
export default {
- name: 'HeaderSearchApp',
+ name: 'GlobalSearchModal',
+ SEARCH_MODAL_ID,
i18n: {
SEARCH_GITLAB,
- SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
- SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
SEARCH_DESCRIBED_BY_DEFAULT,
SEARCH_DESCRIBED_BY_UPDATED,
SEARCH_RESULTS_LOADING,
SEARCH_RESULTS_SCOPE,
- KBD_HELP,
+ MIN_SEARCH_TERM,
},
directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
- HeaderSearchDefaultItems,
- HeaderSearchScopedItems,
- HeaderSearchAutocompleteItems,
- DropdownKeyboardNavigation,
+ GlobalSearchDefaultItems,
+ GlobalSearchScopedItems,
+ GlobalSearchAutocompleteItems,
GlIcon,
GlToken,
- },
- data() {
- return {
- showDropdown: false,
- isFocused: false,
- currentFocusIndex: SEARCH_BOX_INDEX,
- };
+ GlModal,
},
computed: {
...mapState(['search', 'loading', 'searchContext']),
- ...mapGetters(['searchQuery', 'searchOptions']),
+ ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
searchText: {
get() {
return this.search;
@@ -81,51 +72,26 @@ export default {
this.setSearch(value);
},
},
- currentFocusedOption() {
- return this.searchOptions[this.currentFocusIndex];
- },
- currentFocusedId() {
- return this.currentFocusedOption?.html_id;
- },
- isLoggedIn() {
- return Boolean(gon?.current_username);
- },
- showSearchDropdown() {
- if (!this.showDropdown || !this.isLoggedIn) {
- return false;
- }
- return this.searchOptions?.length > 0;
- },
showDefaultItems() {
return !this.searchText;
},
searchTermOverMin() {
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
- defaultIndex() {
- if (this.showDefaultItems) {
- return SEARCH_BOX_INDEX;
- }
- return FIRST_DROPDOWN_INDEX;
- },
-
- searchInputDescribeBy() {
- if (this.isLoggedIn) {
- return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
- }
- return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
+ showScopedSearchItems() {
+ return this.searchTermOverMin && this.scopedSearchOptions.length > 1;
},
- dropdownResultsDescription() {
- if (!this.showSearchDropdown) {
- return ''; // This allows aria-live to see register an update when the dropdown is shown
- }
-
+ searchResultsDescription() {
if (this.showDefaultItems) {
return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
count: this.searchOptions.length,
});
}
+ if (!this.searchTermOverMin) {
+ return this.$options.i18n.MIN_SEARCH_TERM;
+ }
+
return this.loading
? this.$options.i18n.SEARCH_RESULTS_LOADING
: sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
@@ -135,12 +101,10 @@ export default {
searchBarClasses() {
return {
[IS_SEARCHING]: this.searchTermOverMin,
- [IS_FOCUSED]: this.isFocused,
- [IS_NOT_FOCUSED]: !this.isFocused,
};
},
showScopeHelp() {
- return this.searchTermOverMin && this.isFocused;
+ return this.searchTermOverMin;
},
searchBarItem() {
return this.searchOptions?.[0];
@@ -159,47 +123,7 @@ export default {
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
- openDropdown() {
- this.showDropdown = true;
-
- // check isFocused state to avoid firing duplicate events
- if (!this.isFocused) {
- this.isFocused = true;
- this.$emit('expandSearchBar', true);
-
- Tracking.event(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }
- },
- closeDropdown() {
- this.showDropdown = false;
- },
- collapseAndCloseSearchBar() {
- // we need a delay on this method
- // for the search bar not to remove
- // the clear button from dom
- // and register clicks on dropdown items
- setTimeout(() => {
- this.showDropdown = false;
- this.isFocused = false;
- this.$emit('collapseSearchBar');
-
- Tracking.event(undefined, 'blur_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }, 200);
- },
- submitSearch() {
- if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
- return null;
- }
- return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
- },
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
- this.openDropdown();
if (!searchTerm) {
this.clearAutocomplete();
} else {
@@ -216,105 +140,174 @@ export default {
}
inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
},
+ getFocusableOptions() {
+ return Array.from(
+ this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [],
+ );
+ },
+ onKeydown(event) {
+ const { code, target } = event;
+
+ let stop = true;
+
+ const elements = this.getFocusableOptions();
+ if (elements.length < 1) return;
+
+ const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
+
+ if (code === HOME_KEY) {
+ this.focusItem(0, elements);
+ } else if (code === END_KEY) {
+ this.focusItem(elements.length - 1, elements);
+ } else if (code === ARROW_UP_KEY) {
+ if (isSearchInput) return;
+
+ if (elements.indexOf(target) === 0) {
+ this.focusSearchInput();
+ return;
+ }
+ this.focusNextItem(event, elements, -1);
+ } else if (code === ARROW_DOWN_KEY) {
+ this.focusNextItem(event, elements, 1);
+ } else if (code === ESC_KEY) {
+ this.$refs.searchModal.close();
+ } else {
+ stop = false;
+ }
+
+ if (stop) {
+ event.preventDefault();
+ }
+ },
+ focusSearchInput() {
+ this.$refs.searchInputBox.$el.querySelector('input').focus();
+ },
+ focusNextItem(event, elements, offset) {
+ const { target } = event;
+ const currentIndex = elements.indexOf(target);
+ const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
+
+ this.focusItem(nextIndex, elements);
+ },
+ focusItem(index, elements) {
+ this.nextFocusedItemIndex = index;
+
+ elements[index]?.focus();
+ },
+ submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return;
+ }
+ visitUrl(this.searchQuery);
+ },
},
- SEARCH_BOX_INDEX,
- FIRST_DROPDOWN_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
};
</script>
<template>
- <form
- v-outside="closeDropdown"
- role="search"
- :aria-label="$options.i18n.SEARCH_GITLAB"
- class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="searchBarClasses"
- data-testid="header-search-form"
+ <gl-modal
+ ref="searchModal"
+ :modal-id="$options.SEARCH_MODAL_ID"
+ hide-header
+ hide-footer
+ hide-header-close
+ scrollable
+ body-class="gl-p-0!"
+ modal-class="global-search-modal"
+ :centered="false"
>
- <gl-search-box-by-type
- id="search"
- ref="searchInputBox"
- v-model="searchText"
- role="searchbox"
- class="gl-z-index-1"
- data-qa-selector="search_term_field"
- autocomplete="off"
- :placeholder="$options.i18n.SEARCH_GITLAB"
- :aria-activedescendant="currentFocusedId"
- :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
- @focus="openDropdown"
- @click="openDropdown"
- @blur="collapseAndCloseSearchBar"
- @input="getAutocompleteOptions"
- @keydown.enter.stop.prevent="submitSearch"
- @keydown.esc.stop.prevent="closeDropdown"
- />
- <gl-token
- v-if="showScopeHelp"
- v-gl-resize-observer-directive="observeTokenWidth"
- class="in-search-scope-help"
- :view-only="true"
- :title="scopeTokenTitle"
- ><gl-icon
- v-if="infieldHelpIcon"
- class="gl-mr-2"
- :aria-label="infieldHelpContent"
- :name="infieldHelpIcon"
- :size="16"
- />{{
- getTruncatedScope(
- sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
- scope: infieldHelpContent,
- }),
- )
- }}
- </gl-token>
- <kbd
- v-show="!isFocused"
- v-gl-tooltip.bottom.hover.html
- class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
- :title="$options.i18n.KBD_HELP"
- >/</kbd
- >
- <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
- searchInputDescribeBy
- }}</span>
- <span
- role="region"
- :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
- class="gl-sr-only"
- aria-live="polite"
- aria-atomic="true"
+ <form
+ role="search"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
+ class="gl-relative gl-rounded-base gl-w-full"
+ :class="searchBarClasses"
+ data-testid="global-search-form"
>
- {{ dropdownResultsDescription }}
- </span>
- <div
- v-if="showSearchDropdown"
- data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
- >
- <div class="header-search-dropdown-content gl-py-2">
- <dropdown-keyboard-navigation
- v-model="currentFocusIndex"
- :max="searchOptions.length - 1"
- :min="$options.FIRST_DROPDOWN_INDEX"
- :default-index="defaultIndex"
- @tab="closeDropdown"
- />
- <header-search-default-items
- v-if="showDefaultItems"
- :current-focused-option="currentFocusedOption"
+ <div class="gl-p-1">
+ <gl-search-box-by-type
+ id="search"
+ ref="searchInputBox"
+ v-model="searchText"
+ role="searchbox"
+ data-testid="global-search-input"
+ autocomplete="off"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
+ borderless
+ @input="getAutocompleteOptions"
+ @keydown.enter.stop.prevent="submitSearch"
+ @keydown="onKeydown"
/>
- <template v-else>
- <header-search-scoped-items
- v-if="searchTermOverMin"
- :current-focused-option="currentFocusedOption"
+ <gl-token
+ v-if="showScopeHelp"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help gl-sm-display-block gl-display-none"
+ view-only
+ :title="scopeTokenTitle"
+ >
+ <gl-icon
+ v-if="infieldHelpIcon"
+ class="gl-mr-2"
+ :aria-label="infieldHelpContent"
+ :name="infieldHelpIcon"
+ :size="16"
/>
- <header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
+ {{
+ getTruncatedScope(
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }),
+ )
+ }}
+ </gl-token>
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
+ {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }}
+ </span>
+ </div>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ searchResultsDescription }}
+ </span>
+ <div
+ ref="resultsList"
+ data-testid="global-search-results"
+ class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
+ @keydown="onKeydown"
+ >
+ <global-search-default-items v-if="showDefaultItems" />
+ <template v-else>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
</template>
</div>
- </div>
- </form>
+
+ <template v-if="searchContext">
+ <input
+ v-if="searchContext.group"
+ type="hidden"
+ name="group_id"
+ :value="searchContext.group.id"
+ />
+ <input
+ v-if="searchContext.project"
+ type="hidden"
+ name="project_id"
+ :value="searchContext.project.id"
+ />
+
+ <template v-if="searchContext.group || searchContext.project">
+ <input type="hidden" name="scope" :value="searchContext.scope" />
+ <input type="hidden" name="search_code" :value="searchContext.code_search" />
+ </template>
+
+ <input type="hidden" name="snippets" :value="searchContext.for_snippets" />
+ <input type="hidden" name="repository_ref" :value="searchContext.ref" />
+ </template>
+ </form>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
index 1838214def6..cd623200b03 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -1,113 +1,36 @@
<script>
-import {
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
- GlAvatar,
- GlAlert,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import { truncateNamespace } from '~/lib/utils/text_utility';
-import {
- GROUPS_CATEGORY,
- PROJECTS_CATEGORY,
- MERGE_REQUEST_CATEGORY,
- ISSUES_CATEGORY,
- RECENT_EPICS_CATEGORY,
- AUTOCOMPLETE_ERROR_MESSAGE,
-} from '~/vue_shared/global_search/constants';
-import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants';
export default {
- name: 'HeaderSearchAutocompleteItems',
+ name: 'GlobalSearchAutocompleteItems',
i18n: {
AUTOCOMPLETE_ERROR_MESSAGE,
},
components: {
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
GlAvatar,
GlAlert,
GlLoadingIcon,
+ GlDisclosureDropdownGroup,
},
directives: {
SafeHtml,
},
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
- },
computed: {
- ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']),
- ...mapGetters(['autocompleteGroupedSearchOptions']),
- },
- watch: {
- currentFocusedOption() {
- const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
-
- if (focusedElement) {
- focusedElement.scrollIntoView(false);
- }
+ ...mapState(['search', 'loading', 'autocompleteError']),
+ ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
+ isPrecededByScopedOptions() {
+ return this.scopedSearchOptions.length > 1;
},
},
methods: {
- truncateNamespace(string) {
- if (string.split(' / ').length > 2) {
- return truncateNamespace(string);
- }
-
- return string;
- },
highlightedName(val) {
return highlight(val, this.search);
},
- avatarSize(data) {
- if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
- return LARGE_AVATAR_PX;
- }
-
- return SMALL_AVATAR_PX;
- },
- isOptionFocused(data) {
- return this.currentFocusedOption?.html_id === data.html_id;
- },
- isProjectsCategory(data) {
- return data.category === PROJECTS_CATEGORY;
- },
- getEntityId(data) {
- switch (data.category) {
- case GROUPS_CATEGORY:
- case RECENT_EPICS_CATEGORY:
- return data.group_id || data.id || this.searchContext?.group?.id;
- case PROJECTS_CATEGORY:
- case ISSUES_CATEGORY:
- case MERGE_REQUEST_CATEGORY:
- return data.project_id || data.id || this.searchContext?.project?.id;
- default:
- return data.id;
- }
- },
- getEntitytName(data) {
- switch (data.category) {
- case GROUPS_CATEGORY:
- case RECENT_EPICS_CATEGORY:
- return data.group_name || data.value || data.label || this.searchContext?.group?.name;
- case PROJECTS_CATEGORY:
- case ISSUES_CATEGORY:
- case MERGE_REQUEST_CATEGORY:
- return data.project_name || data.value || data.label || this.searchContext?.project?.name;
- default:
- return data.label;
- }
- },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -115,46 +38,46 @@ export default {
<template>
<div>
- <template v-if="!loading">
- <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category">
- <gl-dropdown-divider v-if="index > 0" />
- <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="data in option.data"
- :id="data.html_id"
- :ref="data.html_id"
- :key="data.html_id"
- :class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
- :aria-selected="isOptionFocused(data)"
- :aria-label="data.label"
- tabindex="-1"
- :href="data.url"
- >
- <div class="gl-display-flex gl-align-items-center" aria-hidden="true">
+ <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group
+ v-for="group in autocompleteGroupedSearchOptions"
+ :key="group.name"
+ :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }"
+ :group="group"
+ bordered
+ >
+ <template #list-item="{ item }">
+ <div class="gl-display-flex gl-align-items-center">
<gl-avatar
- v-if="data.avatar_url !== undefined"
- :src="data.avatar_url"
- :entity-id="getEntityId(data)"
- :entity-name="getEntitytName(data)"
- :size="avatarSize(data)"
+ v-if="item.avatar_url !== undefined"
+ class="gl-mr-3"
+ :src="item.avatar_url"
+ :entity-id="item.entity_id"
+ :entity-name="item.entity_name"
+ :size="item.avatar_size"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ aria-hidden="true"
/>
<span class="gl-display-flex gl-flex-direction-column">
<span
- v-safe-html="highlightedName(data.value || data.label)"
+ v-safe-html="highlightedName(item.text)"
class="gl-text-gray-900"
+ data-testid="autocomplete-item-name"
></span>
<span
- v-if="data.value"
- v-safe-html="truncateNamespace(data.label)"
+ v-if="item.value"
+ v-safe-html="item.namespace"
class="gl-font-sm gl-text-gray-500"
+ data-testid="autocomplete-item-namespace"
></span>
</span>
</div>
- </gl-dropdown-item>
- </div>
- </template>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
+
<gl-loading-icon v-else size="lg" class="my-4" />
+
<gl-alert
v-if="autocompleteError"
class="gl-text-body gl-mt-2"
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
index f0d398297e9..239c61fd750 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -1,23 +1,15 @@
<script>
-import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
export default {
- name: 'HeaderSearchDefaultItems',
+ name: 'GlobalSearchDefaultItems',
i18n: {
ALL_GITLAB,
},
components: {
- GlDropdownSectionHeader,
- GlDropdownItem,
- },
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
+ GlDisclosureDropdownGroup,
},
computed: {
...mapState(['searchContext']),
@@ -29,30 +21,18 @@ export default {
this.$options.i18n.ALL_GITLAB
);
},
- },
- methods: {
- isOptionFocused(option) {
- return this.currentFocusedOption?.html_id === option.html_id;
+ defaultItemsGroup() {
+ return {
+ name: this.sectionHeader,
+ items: this.defaultSearchOptions,
+ };
},
},
};
</script>
<template>
- <div>
- <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="option in defaultSearchOptions"
- :id="option.html_id"
- :ref="option.html_id"
- :key="option.html_id"
- :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
- :aria-selected="isOptionFocused(option)"
- :aria-label="option.title"
- tabindex="-1"
- :href="option.url"
- >
- <span aria-hidden="true">{{ option.title }}</span>
- </gl-dropdown-item>
- </div>
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" />
+ </ul>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
index 1ef88492b23..76600f829f6 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -1,47 +1,26 @@
<script>
-import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
+import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
-import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants';
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
- name: 'HeaderSearchScopedItems',
- i18n: {
- SCOPED_SEARCH_ITEM_ARIA_LABEL,
- },
+ name: 'GlobalSearchScopedItems',
components: {
- GlDropdownItem,
GlIcon,
GlToken,
- },
- props: {
- currentFocusedOption: {
- type: Object,
- required: false,
- default: () => null,
- },
+ GlDisclosureDropdownGroup,
},
computed: {
...mapState(['search']),
- ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']),
+ ...mapGetters(['scopedSearchGroup']),
},
methods: {
- isOptionFocused(option) {
- return this.currentFocusedOption?.html_id === option.html_id;
- },
- ariaLabel(option) {
- return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
- search: this.search,
- description: option.description || option.icon,
- scope: option.scope || '',
- });
- },
- titleLabel(option) {
+ titleLabel(item) {
return sprintf(s__('GlobalSearch|in %{scope}'), {
search: this.search,
- scope: option.scope || option.description,
+ scope: item.scope || item.description,
});
},
getTruncatedScope(scope) {
@@ -53,35 +32,23 @@ export default {
<template>
<div>
- <gl-dropdown-item
- v-for="option in scopedSearchOptions"
- :id="option.html_id"
- :ref="option.html_id"
- :key="option.html_id"
- class="gl-max-w-full"
- :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
- :aria-selected="isOptionFocused(option)"
- :aria-label="ariaLabel(option)"
- tabindex="-1"
- :href="option.url"
- :title="titleLabel(option)"
- >
- <span
- ref="token-text-content"
- class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
- >
- <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
- <span class="gl-flex-grow-1 gl-relative">
- <gl-token
- class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
- :view-only="true"
+ <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!">
+ <template #list-item="{ item }">
+ <span
+ class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full"
>
- <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
- <span>{{ getTruncatedScope(titleLabel(option)) }}</span>
- </gl-token>
- {{ search }}
- </span>
- </span>
- </gl-dropdown-item>
+ <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" />
+ <span class="gl-flex-grow-1">
+ <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only>
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(item)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
index b9bb4e573fd..cb267df6122 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/constants.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -8,10 +8,6 @@ export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
-export const FIRST_DROPDOWN_INDEX = 0;
-
-export const SEARCH_BOX_INDEX = -1;
-
export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
@@ -20,14 +16,13 @@ export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
export const SCOPE_TOKEN_MAX_LENGTH = 36;
-export const INPUT_FIELD_PADDING = 52;
-
-export const HEADER_INIT_EVENTS = ['input', 'focus'];
+export const INPUT_FIELD_PADDING = 84;
export const IS_SEARCHING = 'is-searching';
-export const IS_FOCUSED = 'is-focused';
-export const IS_NOT_FOCUSED = 'is-not-focused';
export const FETCH_TYPES = ['generic', 'search'];
+export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
+
+export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
-export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
+export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
index f86463b94d1..4a42f416206 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -1,6 +1,5 @@
import { omitBy, isNil } from 'lodash';
import { objectToQuery } from '~/lib/utils/url_utility';
-
import {
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
@@ -10,8 +9,10 @@ import {
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
- DROPDOWN_ORDER,
+ SEARCH_RESULTS_ORDER,
} from '~/vue_shared/global_search/constants';
+import { getFormattedItem } from '../utils';
+
import {
ICON_GROUP,
ICON_SUBGROUP,
@@ -62,32 +63,27 @@ export const defaultSearchOptions = (state, getters) => {
const issues = [
{
- html_id: 'default-issues-assigned',
- title: MSG_ISSUES_ASSIGNED_TO_ME,
- url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ text: MSG_ISSUES_ASSIGNED_TO_ME,
+ href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
},
{
- html_id: 'default-issues-created',
- title: MSG_ISSUES_IVE_CREATED,
- url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ text: MSG_ISSUES_IVE_CREATED,
+ href: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
];
const mergeRequests = [
{
- html_id: 'default-mrs-assigned',
- title: MSG_MR_ASSIGNED_TO_ME,
- url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ text: MSG_MR_ASSIGNED_TO_ME,
+ href: `${getters.scopedMRPath}/?assignee_username=${userName}`,
},
{
- html_id: 'default-mrs-reviewer',
- title: MSG_MR_IM_REVIEWER,
- url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ text: MSG_MR_IM_REVIEWER,
+ href: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
},
{
- html_id: 'default-mrs-created',
- title: MSG_MR_IVE_CREATED,
- url: `${getters.scopedMRPath}/?author_username=${userName}`,
+ text: MSG_MR_IVE_CREATED,
+ href: `${getters.scopedMRPath}/?author_username=${userName}`,
},
];
return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
@@ -145,58 +141,64 @@ export const allUrl = (state) => {
};
export const scopedSearchOptions = (state, getters) => {
- const options = [];
+ const items = [];
if (state.searchContext?.project) {
- options.push({
- html_id: 'scoped-in-project',
+ items.push({
+ text: 'scoped-in-project',
scope: state.searchContext.project?.name || '',
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
- url: getters.projectUrl,
+ href: getters.projectUrl,
});
}
if (state.searchContext?.group) {
- options.push({
- html_id: 'scoped-in-group',
+ items.push({
+ text: 'scoped-in-group',
scope: state.searchContext.group?.name || '',
scopeCategory: GROUPS_CATEGORY,
icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
- url: getters.groupUrl,
+ href: getters.groupUrl,
});
}
- options.push({
- html_id: 'scoped-in-all',
+ items.push({
+ text: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
- url: getters.allUrl,
+ href: getters.allUrl,
});
- return options;
+ return items;
+};
+
+export const scopedSearchGroup = (state, getters) => {
+ const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : [];
+ return { items };
};
export const autocompleteGroupedSearchOptions = (state) => {
const groupedOptions = {};
const results = [];
- state.autocompleteOptions.forEach((option) => {
- const category = groupedOptions[option.category];
+ state.autocompleteOptions.forEach((item) => {
+ const group = groupedOptions[item.category];
+ const formattedItem = getFormattedItem(item, state.searchContext);
- if (category) {
- category.data.push(option);
+ if (group) {
+ group.items.push(formattedItem);
} else {
- groupedOptions[option.category] = {
- category: option.category,
- data: [option],
+ groupedOptions[item.category] = {
+ name: formattedItem.category,
+ items: [formattedItem],
};
- results.push(groupedOptions[option.category]);
+ results.push(groupedOptions[formattedItem.category]);
}
});
return results.sort(
- (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+ (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name),
);
};
@@ -206,8 +208,8 @@ export const searchOptions = (state, getters) => {
}
const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
- (options, group) => {
- return [...options, ...group.data];
+ (items, group) => {
+ return [...items, ...group.items];
},
[],
);
@@ -216,5 +218,5 @@ export const searchOptions = (state, getters) => {
return sortedAutocompleteOptions;
}
- return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
+ return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions);
};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
index 6e65345757f..d7d9ebecd16 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
@@ -2,5 +2,4 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE';
-
export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
index 19b4d4ec330..9936c3f59d8 100644
--- a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -8,11 +8,7 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
- state.autocompleteOptions = [...state.autocompleteOptions].concat(
- data.map((d, i) => {
- return { html_id: `autocomplete-${d.category}-${i}`, ...d };
- }),
- );
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(data);
state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
new file mode 100644
index 00000000000..11d1fa1ab95
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
@@ -0,0 +1,81 @@
+import { pickBy } from 'lodash';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants';
+
+const getTruncatedNamespace = (string) => {
+ if (string.split(' / ').length > 2) {
+ return truncateNamespace(string);
+ }
+
+ return string;
+};
+const getAvatarSize = (category) => {
+ if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+};
+
+const getEntityId = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_id || item.id || searchContext?.group?.id;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_id || item.id || searchContext?.project?.id;
+ default:
+ return item.id;
+ }
+};
+const getEntityName = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_name || item.value || item.label || searchContext?.group?.name;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_name || item.value || item.label || searchContext?.project?.name;
+ default:
+ return item.label;
+ }
+};
+
+export const getFormattedItem = (item, searchContext) => {
+ const { id, category, value, label, url: href, avatar_url } = item;
+ let namespace;
+ const text = value || label;
+ if (value) {
+ namespace = getTruncatedNamespace(label);
+ }
+ const avatarSize = getAvatarSize(category);
+ const entityId = getEntityId(item, searchContext);
+ const entityName = getEntityName(item, searchContext);
+
+ return pickBy(
+ {
+ id,
+ category,
+ value,
+ label,
+ text,
+ href,
+ avatar_url,
+ avatar_size: avatarSize,
+ namespace,
+ entity_id: entityId,
+ entity_name: entityName,
+ },
+ (val) => val !== undefined,
+ );
+};
diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue
index 78b5ed2d31e..4fa15f1cd76 100644
--- a/app/assets/javascripts/super_sidebar/components/groups_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -36,16 +36,19 @@ export default {
storageKey() {
return `${this.username}/frequent-groups`;
},
- viewAllItem() {
+ viewAllProps() {
return {
- link: this.viewAllLink,
- title: s__('Navigation|View all groups'),
- icon: 'group',
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your groups'),
+ icon: 'group',
+ },
+ linkClasses: { 'dashboard-shortcuts-groups': true },
};
},
},
i18n: {
- title: s__('Navigation|Frequent groups'),
+ title: s__('Navigation|Frequently visited groups'),
searchTitle: s__('Navigation|Groups'),
pristineText: s__('Navigation|Groups you visit often will appear here.'),
noResultsText: s__('Navigation|No group matches found'),
@@ -61,7 +64,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</search-results>
<frequent-items-list
@@ -72,7 +75,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</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 fb23a4f2deb..01b214f4b2b 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -5,6 +5,11 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import { __ } from '~/locale';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
+import Tracking from '~/tracking';
+import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -4;
export default {
components: {
@@ -14,6 +19,7 @@ export default {
GlDisclosureDropdownGroup,
GitlabVersionCheckBadge,
},
+ mixins: [Tracking.mixin({ property: 'nav_help_menu' })],
i18n: {
help: __('Help'),
support: __('Support'),
@@ -46,21 +52,63 @@ export default {
text: this.$options.i18n.version,
href: helpPagePath('update/index'),
version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`,
+ extraAttrs: {
+ ...this.trackingAttrs('version_help_dropdown'),
+ },
},
],
},
helpLinks: {
items: [
- { text: this.$options.i18n.help, href: helpPagePath() },
- { text: this.$options.i18n.support, href: this.sidebarData.support_path },
- { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' },
- { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` },
- { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' },
+ {
+ text: this.$options.i18n.help,
+ href: helpPagePath(),
+ extraAttrs: {
+ ...this.trackingAttrs('help'),
+ },
+ },
+ {
+ text: this.$options.i18n.support,
+ href: this.sidebarData.support_path,
+ extraAttrs: {
+ ...this.trackingAttrs('support'),
+ },
+ },
+ {
+ text: this.$options.i18n.docs,
+ href: 'https://docs.gitlab.com',
+ extraAttrs: {
+ ...this.trackingAttrs('gitlab_documentation'),
+ },
+ },
+ {
+ text: this.$options.i18n.plans,
+ href: `${PROMO_URL}/pricing`,
+ extraAttrs: {
+ ...this.trackingAttrs('compare_gitlab_plans'),
+ },
+ },
+ {
+ text: this.$options.i18n.forum,
+ href: 'https://forum.gitlab.com/',
+ extraAttrs: {
+ ...this.trackingAttrs('community_forum'),
+ },
+ },
{
text: this.$options.i18n.contribute,
href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
+ extraAttrs: {
+ ...this.trackingAttrs('contribute_to_gitlab'),
+ },
+ },
+ {
+ text: this.$options.i18n.feedback,
+ href: 'https://about.gitlab.com/submit-feedback',
+ extraAttrs: {
+ ...this.trackingAttrs('submit_feedback'),
+ },
},
- { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' },
],
},
helpActions: {
@@ -70,6 +118,9 @@ export default {
action: this.showKeyboardShortcuts,
extraAttrs: {
class: 'js-shortcuts-modal-trigger',
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'keyboard_shortcuts_help',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
},
shortcut: '?',
},
@@ -79,6 +130,11 @@ export default {
count:
this.showWhatsNewNotification &&
this.sidebarData.whats_new_most_recent_release_items_count,
+ extraAttrs: {
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'whats_new',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
+ },
},
].filter(Boolean),
},
@@ -118,12 +174,40 @@ export default {
this.toggleWhatsNewDrawer();
}
},
+
+ trackingAttrs(label) {
+ return {
+ ...HELP_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': label,
+ };
+ },
+
+ trackDropdownToggle(show) {
+ this.track('click_toggle', {
+ label: show ? 'show_help_dropdown' : 'hide_help_dropdown',
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
},
};
</script>
<template>
- <gl-disclosure-dropdown ref="dropdown">
+ <gl-disclosure-dropdown
+ ref="dropdown"
+ :popper-options="$options.popperOptions"
+ @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>
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
index 0a72105fcc4..8ee7d57c47c 100644
--- a/app/assets/javascripts/super_sidebar/components/items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -1,18 +1,29 @@
<script>
+import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import NavItem from './nav_item.vue';
export default {
components: {
+ GlIcon,
+ GlButton,
ProjectAvatar,
NavItem,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
items: {
type: Array,
required: false,
default: () => [],
},
+ editable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
};
</script>
@@ -34,6 +45,21 @@ export default {
aria-hidden="true"
/>
</template>
+ <template #actions>
+ <gl-button
+ v-if="editable"
+ v-gl-tooltip.left
+ size="small"
+ category="tertiary"
+ :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)"
+ >
+ <gl-icon name="close" />
+ </gl-button>
+ </template>
</nav-item>
<slot name="view-all-items"></slot>
</ul>
diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
index 94fc6aedcc0..d37e863bed9 100644
--- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -16,7 +16,12 @@ export default {
</script>
<template>
- <gl-disclosure-dropdown :items="items" placement="center">
+ <gl-disclosure-dropdown
+ :items="items"
+ placement="center"
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
+ >
<template #toggle>
<slot></slot>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index cd5363ad7a5..223fbe6d078 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -1,15 +1,41 @@
<script>
import { kebabCase } from 'lodash';
-import { GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
+import { GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ CLICK_MENU_ITEM_ACTION,
+ TRACKING_UNKNOWN_ID,
+ TRACKING_UNKNOWN_PANEL,
+} from '~/super_sidebar/constants';
export default {
+ i18n: {
+ pinItem: s__('Navigation|Pin item'),
+ unpinItem: s__('Navigation|Unpin item'),
+ },
name: 'NavItem',
components: {
+ GlButton,
GlCollapse,
GlIcon,
GlBadge,
},
+ inject: {
+ pinnedItemIds: { default: { ids: [] } },
+ panelSupportsPins: { default: false },
+ panelType: { default: '' },
+ },
props: {
+ draggable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isStatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
item: {
type: Object,
required: true,
@@ -53,25 +79,53 @@ export default {
}
return this.item.is_active;
},
+ isPinnable() {
+ return this.panelSupportsPins && !this.isSection && !this.isStatic;
+ },
+ isPinned() {
+ return this.pinnedItemIds.ids.includes(this.item.id);
+ },
+ trackingProps() {
+ // Set extra event data to debug missing IDs / Panel Types
+ const extraData =
+ !this.item.id || !this.panelType
+ ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) }
+ : {};
+
+ return {
+ 'data-track-action': CLICK_MENU_ITEM_ACTION,
+ 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID,
+ 'data-track-property': this.panelType
+ ? `nav_panel_${this.panelType}`
+ : TRACKING_UNKNOWN_PANEL,
+ ...extraData,
+ };
+ },
linkProps() {
if (this.isSection) {
return {
'aria-controls': this.itemId,
'aria-expanded': String(this.expanded),
+ 'data-qa-menu-item': this.item.title,
};
}
return {
...this.$attrs,
+ ...this.trackingProps,
href: this.item.link,
'aria-current': this.isActive ? 'page' : null,
+ 'data-qa-submenu-item': this.item.title,
};
},
computedLinkClasses() {
return {
// Reset user agent styles on <button>
'gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left': this.isSection,
- 'gl-w-full gl-focus': this.isSection,
+ 'gl-w-full gl-focus--focus': this.isSection,
+ 'nav-item-link': !this.isSection,
'gl-bg-t-gray-a-08': this.isActive,
+ 'gl-py-2': this.isPinnable,
+ 'gl-py-3': !this.isPinnable,
...this.linkClasses,
};
},
@@ -92,11 +146,10 @@ export default {
<component
:is="elem"
v-bind="linkProps"
- class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-text-decoration-none!"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 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!"
:class="computedLinkClasses"
- data-qa-selector="sidebar_menu_link"
+ data-qa-selector="nav_item_link"
data-testid="nav-item-link"
- :data-qa-menu-item="item.title"
@click="click"
>
<div
@@ -107,26 +160,50 @@ export default {
></div>
<div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
<slot name="icon">
- <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ <gl-icon
+ v-else-if="draggable"
+ name="grip"
+ class="gl-text-gray-400 gl-ml-2 draggable-icon"
+ />
</slot>
</div>
- <div class="gl-pr-3 gl-text-gray-900">
+ <div class="gl-pr-3 gl-text-gray-900 gl-truncate-end">
{{ item.title }}
- <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500">
+ <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end">
{{ item.subtitle }}
</div>
</div>
- <span v-if="isSection || hasPill" class="gl-flex-grow-1 gl-text-right gl-mr-3">
+ <slot name="actions"></slot>
+ <span v-if="isSection || hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3">
<gl-badge v-if="hasPill" size="sm" variant="info">
{{ pillData }}
</gl-badge>
<gl-icon v-else-if="isSection" :name="collapseIcon" />
+ <gl-button
+ v-else-if="isPinnable && !isPinned"
+ size="small"
+ category="tertiary"
+ icon="thumbtack"
+ :aria-label="$options.i18n.pinItem"
+ @click.prevent="$emit('pin-add', item.id)"
+ />
+ <gl-button
+ v-else-if="isPinnable && isPinned"
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.unpinItem"
+ icon="thumbtack-solid"
+ @click.prevent="$emit('pin-remove', item.id)"
+ />
</span>
</component>
<gl-collapse
v-if="isSection"
:id="itemId"
v-model="expanded"
+ data-qa-selector="menu_section"
+ :data-qa-section="item.title"
:aria-label="item.title"
class="gl-list-style-none gl-p-0"
tag="ul"
@@ -135,6 +212,8 @@ export default {
v-for="subItem of item.items"
:key="`${item.title}-${subItem.title}`"
:item="subItem"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
/>
</gl-collapse>
</li>
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
new file mode 100644
index 00000000000..9595bdfb632
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlCollapse, GlIcon } from '@gitlab/ui';
+import Draggable from 'vuedraggable';
+import { s__ } from '~/locale';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants';
+import NavItem from './nav_item.vue';
+
+export default {
+ i18n: {
+ pinned: s__('Navigation|Pinned'),
+ emptyHint: s__('Navigation|Your pinned items appear here.'),
+ },
+ name: 'PinnedSection',
+ components: {
+ Draggable,
+ GlCollapse,
+ GlIcon,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false',
+ draggableItems: this.items,
+ };
+ },
+ computed: {
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ itemIds() {
+ return this.draggableItems.map((item) => item.id);
+ },
+ },
+ watch: {
+ expanded(newExpanded) {
+ setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, {
+ expires: SIDEBAR_COOKIE_EXPIRATION,
+ });
+ },
+ items(newItems) {
+ this.draggableItems = newItems;
+ },
+ },
+ methods: {
+ handleDrag(event) {
+ if (event.oldIndex === event.newIndex) return;
+ this.$emit(
+ 'pin-reorder',
+ this.items[event.oldIndex].id,
+ this.items[event.newIndex].id,
+ event.oldIndex < event.newIndex,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="gl-mx-2">
+ <a
+ href="#"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center 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!"
+ @click.prevent="expanded = !expanded"
+ >
+ <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
+ <gl-icon name="thumbtack" class="gl-ml-2 item-icon" />
+ </div>
+
+ <span class="gl-font-weight-bold gl-font-sm gl-flex-grow-1">{{ $options.i18n.pinned }}</span>
+ <gl-icon :name="collapseIcon" class="gl-mr-3" />
+ </a>
+ <gl-collapse v-model="expanded">
+ <draggable
+ v-if="items.length > 0"
+ v-model="draggableItems"
+ class="gl-p-0 gl-m-0"
+ data-testid="pinned-nav-items"
+ handle=".draggable-icon"
+ tag="ul"
+ @end="handleDrag"
+ >
+ <nav-item
+ v-for="item of draggableItems"
+ :key="item.id"
+ draggable
+ :item="item"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </draggable>
+ <div v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem">
+ {{ $options.i18n.emptyHint }}
+ </div>
+ </gl-collapse>
+ <hr aria-hidden="true" class="gl-my-2 gl-mx-4" />
+ </section>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
index a545de06bd4..78860e35eb1 100644
--- a/app/assets/javascripts/super_sidebar/components/projects_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -36,16 +36,19 @@ export default {
storageKey() {
return `${this.username}/frequent-projects`;
},
- viewAllItem() {
+ viewAllProps() {
return {
- link: this.viewAllLink,
- title: s__('Navigation|View all projects'),
- icon: 'project',
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your projects'),
+ icon: 'project',
+ },
+ linkClasses: { 'dashboard-shortcuts-projects': true },
};
},
},
i18n: {
- title: s__('Navigation|Frequent projects'),
+ title: s__('Navigation|Frequently visited projects'),
searchTitle: s__('Navigation|Projects'),
pristineText: s__('Navigation|Projects you visit often will appear here.'),
noResultsText: s__('Navigation|No project matches found'),
@@ -62,7 +65,7 @@ export default {
:search-results="searchResults"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</search-results>
<frequent-items-list
@@ -73,7 +76,7 @@ export default {
:pristine-text="$options.i18n.pristineText"
>
<template #view-all-items>
- <nav-item :item="viewAllItem" />
+ <nav-item v-bind="viewAllProps" />
</template>
</frequent-items-list>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue
index 7c172110bad..cfd6184bc47 100644
--- a/app/assets/javascripts/super_sidebar/components/search_results.vue
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -1,10 +1,17 @@
<script>
+import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
import ItemsList from './items_list.vue';
export default {
components: {
+ GlCollapse,
+ GlIcon,
ItemsList,
},
+ directives: {
+ CollapseToggle: GlCollapseToggleDirective,
+ },
props: {
title: {
type: String,
@@ -20,30 +27,69 @@ export default {
default: () => [],
},
},
+ data() {
+ return {
+ expanded: true,
+ };
+ },
computed: {
isEmpty() {
return !this.searchResults.length;
},
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ },
+ created() {
+ this.collapseId = uniqueId('expandable-section-');
},
+ buttonClasses: [
+ // Reset user agent styles
+ 'gl-appearance-none',
+ 'gl-border-0',
+ 'gl-bg-transparent',
+ // Text styles
+ 'gl-text-left',
+ 'gl-text-transform-uppercase',
+ 'gl-text-secondary',
+ 'gl-font-weight-bold',
+ 'gl-font-xs',
+ 'gl-line-height-12',
+ 'gl-letter-spacing-06em',
+ // Border
+ 'gl-border-t',
+ 'gl-border-gray-50',
+ // Spacing
+ 'gl-my-3',
+ 'gl-pt-2',
+ 'gl-w-full',
+ // Layout
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ],
};
</script>
<template>
- <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3">
- <div
- data-testid="list-title"
- aria-hidden="true"
- class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ <li class="gl-border-t gl-border-gray-50 gl-mx-3">
+ <button
+ v-collapse-toggle="collapseId"
+ :class="$options.buttonClasses"
+ data-testid="search-results-toggle"
>
{{ title }}
- </div>
- <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
- {{ noResultsText }}
- </div>
- <items-list :aria-label="title" :items="searchResults">
- <template #view-all-items>
- <slot name="view-all-items"></slot>
- </template>
- </items-list>
+ <gl-icon :name="collapseIcon" :size="16" />
+ </button>
+ <gl-collapse :id="collapseId" v-model="expanded">
+ <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
+ {{ noResultsText }}
+ </div>
+ <items-list :aria-label="title" :items="searchResults">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </gl-collapse>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
index fc8968c50ea..ca165dd8602 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -1,24 +1,157 @@
<script>
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { PANELS_WITH_PINS } from '../constants';
import NavItem from './nav_item.vue';
+import PinnedSection from './pinned_section.vue';
export default {
name: 'SidebarMenu',
components: {
NavItem,
+ PinnedSection,
+ },
+
+ provide() {
+ return {
+ pinnedItemIds: this.changedPinnedItemIds,
+ panelSupportsPins: this.supportsPins,
+ panelType: this.panelType,
+ };
},
props: {
items: {
type: Array,
required: true,
},
+ pinnedItemIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ panelType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatePinsUrl: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ // This is used as a provide and injected into the nav items.
+ // Note: It has to be an object to be reactive.
+ changedPinnedItemIds: { ids: this.pinnedItemIds },
+ };
+ },
+
+ computed: {
+ // Returns the list of items that we want to have static at the top.
+ // Only sidebars that support pins also support a static section.
+ staticItems() {
+ if (!this.supportsPins) return [];
+ return this.items.filter((item) => !item.items || item.items.length === 0);
+ },
+
+ // Returns only the items that aren't static at the top and makes sure no
+ // section shows as active (and expanded) when one of its items is pinned.
+ nonStaticItems() {
+ if (!this.supportsPins) return this.items;
+
+ return this.items
+ .filter((item) => item.items && item.items.length > 0)
+ .map((item) => {
+ const hasActivePinnedChild = item.items.some((childItem) => {
+ return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id);
+ });
+ const showAsActive = item.is_active && !hasActivePinnedChild;
+
+ return { ...item, is_active: showAsActive };
+ });
+ },
+
+ // Returns a flat list of all items that are in sections, but not the sections.
+ // Only items from sections (item.items) can be pinned.
+ flatPinnableItems() {
+ return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean);
+ },
+
+ pinnedItems() {
+ return this.changedPinnedItemIds.ids
+ .map((id) => this.flatPinnableItems.find((item) => item.id === id))
+ .filter(Boolean);
+ },
+ supportsPins() {
+ return PANELS_WITH_PINS.includes(this.panelType);
+ },
+ },
+ methods: {
+ createPin(itemId) {
+ this.changedPinnedItemIds.ids.push(itemId);
+ this.updatePins();
+ },
+ destroyPin(itemId) {
+ this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId);
+ this.updatePins();
+ },
+ movePin(fromId, toId, isDownwards) {
+ const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId);
+ this.changedPinnedItemIds.ids.splice(fromIndex, 1);
+
+ let toIndex = this.changedPinnedItemIds.ids.indexOf(toId);
+
+ // If the item was moved downwards, we insert it *after* the item it was dragged on to.
+ // This matches how vuedraggable previews the change while still dragging.
+ if (isDownwards) toIndex += 1;
+
+ this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId);
+
+ this.updatePins();
+ },
+ updatePins() {
+ axios
+ .put(this.updatePinsUrl, {
+ panel: this.panelType,
+ menu_item_ids: this.changedPinnedItemIds.ids,
+ })
+ .then((response) => {
+ this.changedPinnedItemIds.ids = response.data;
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ });
+ },
},
};
</script>
<template>
<nav class="gl-py-2 gl-relative">
+ <section v-if="staticItems.length > 0" class="gl-mx-2">
+ <ul class="gl-p-0 gl-m-0">
+ <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static />
+ </ul>
+ <hr aria-hidden="true" class="gl-my-2 gl-mx-4" />
+ </section>
+
+ <pinned-section
+ v-if="supportsPins"
+ :items="pinnedItems"
+ @pin-remove="destroyPin"
+ @pin-reorder="movePin"
+ />
+
<ul class="gl-px-2 gl-list-style-none">
- <nav-item v-for="item in items" :key="`menu-${item.title}`" :item="item" />
+ <nav-item
+ v-for="item in nonStaticItems"
+ :key="item.id"
+ :item="item"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
</ul>
</nav>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index e8df534346b..4b54e317639 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,7 +1,13 @@
<script>
import { GlButton, GlCollapse } from '@gitlab/ui';
import { __ } from '~/locale';
-import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ sidebarState,
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+} from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
@@ -20,6 +26,7 @@ export default {
SidebarMenu,
SidebarPortalTarget,
},
+ mixins: [glFeatureFlagsMixin()],
i18n: {
skipToMainContent: __('Skip to main content'),
},
@@ -30,10 +37,7 @@ export default {
},
},
data() {
- return {
- contextSwitcherOpened: false,
- isCollapased: isCollapsed(),
- };
+ return sidebarState;
},
computed: {
menuItems() {
@@ -44,6 +48,37 @@ export default {
collapseSidebar() {
toggleSuperSidebarCollapsed(true, false);
},
+ onContextSwitcherShown() {
+ this.$refs['context-switcher'].focusInput();
+ },
+ onHoverAreaMouseEnter() {
+ this.openPeekTimer = setTimeout(this.openPeek, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ },
+ onHoverAreaMouseLeave() {
+ clearTimeout(this.openPeekTimer);
+ },
+ onSidebarMouseEnter() {
+ clearTimeout(this.closePeekTimer);
+ },
+ onSidebarMouseLeave() {
+ this.closePeekTimer = setTimeout(this.closePeek, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ },
+ closePeek() {
+ if (this.isPeek) {
+ this.isPeek = false;
+ this.isCollapsed = true;
+ }
+ },
+ openPeek() {
+ this.isPeek = true;
+ this.isCollapsed = false;
+
+ // Cancel and start the timer to close sidebar, in case the user moves
+ // the cursor fast enough away to not trigger a mouseenter event.
+ // This is cancelled if the user moves the cursor into the sidebar.
+ this.onSidebarMouseEnter();
+ this.onSidebarMouseLeave();
+ },
},
};
</script>
@@ -51,14 +86,22 @@ export default {
<template>
<div>
<div class="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <div
+ v-if="!isPeek && glFeatures.superSidebarPeek"
+ class="super-sidebar-hover-area gl-fixed gl-left-0 gl-top-0 gl-bottom-0 gl-w-3"
+ data-testid="super-sidebar-hover-area"
+ @mouseenter="onHoverAreaMouseEnter"
+ @mouseleave="onHoverAreaMouseLeave"
+ ></div>
<aside
id="super-sidebar"
- :aria-hidden="String(isCollapased)"
class="super-sidebar"
+ :class="{ 'super-sidebar-peek': isPeek }"
data-testid="super-sidebar"
data-qa-selector="navbar"
- :inert="isCollapased"
- tabindex="-1"
+ :inert="isCollapsed"
+ @mouseenter="onSidebarMouseEnter"
+ @mouseleave="onSidebarMouseLeave"
>
<gl-button
class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3"
@@ -67,23 +110,36 @@ export default {
>
{{ $options.i18n.skipToMainContent }}
</gl-button>
- <user-bar :sidebar-data="sidebarData" />
+ <user-bar :has-collapse-button="!isPeek" :sidebar-data="sidebarData" />
<div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
<div class="gl-flex-grow-1 gl-overflow-auto">
<context-switcher-toggle
:context="sidebarData.current_context_header"
- :expanded="contextSwitcherOpened"
+ :expanded="contextSwitcherOpen"
+ data-qa-selector="context_switcher"
/>
- <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
+ <gl-collapse
+ id="context-switcher"
+ v-model="contextSwitcherOpen"
+ data-qa-selector="context_section"
+ @shown="onContextSwitcherShown"
+ >
<context-switcher
+ ref="context-switcher"
+ :persistent-links="sidebarData.context_switcher_links"
:username="sidebarData.username"
:projects-path="sidebarData.projects_path"
:groups-path="sidebarData.groups_path"
:current-context="sidebarData.current_context"
/>
</gl-collapse>
- <gl-collapse :visible="!contextSwitcherOpened">
- <sidebar-menu :items="menuItems" />
+ <gl-collapse :visible="!contextSwitcherOpen">
+ <sidebar-menu
+ :items="menuItems"
+ :panel-type="sidebarData.panel_type"
+ :pinned-item-ids="sidebarData.pinned_items"
+ :update-pins-url="sidebarData.update_pins_url"
+ />
<sidebar-portal-target />
</gl-collapse>
</div>
@@ -92,5 +148,14 @@ export default {
</div>
</div>
</aside>
+ <a
+ v-for="shortcutLink in sidebarData.shortcut_links"
+ :key="shortcutLink.href"
+ :href="shortcutLink.href"
+ :class="shortcutLink.css_class"
+ class="gl-display-none"
+ >
+ {{ shortcutLink.title }}
+ </a>
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
new file mode 100644
index 00000000000..3064b91ca7d
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tooltipContainer: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'right',
+ },
+ },
+ i18n: {
+ collapseSidebar: __('Collapse sidebar'),
+ expandSidebar: __('Expand sidebar'),
+ navigationSidebar: __('Navigation sidebar'),
+ },
+ data() {
+ return sidebarState;
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.isPeek) return '';
+
+ return this.isCollapsed
+ ? this.$options.i18n.expandSidebar
+ : this.$options.i18n.collapseSidebar;
+ },
+ tooltip() {
+ return {
+ placement: this.tooltipPlacement,
+ container: this.tooltipContainer,
+ title: this.tooltipTitle,
+ };
+ },
+ ariaExpanded() {
+ return String(!this.isCollapsed);
+ },
+ },
+ methods: {
+ toggle() {
+ toggleSuperSidebarCollapsed(!this.isCollapsed, true);
+ this.focusOtherToggle();
+ },
+ focusOtherToggle() {
+ this.$nextTick(() => {
+ const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
+ const otherToggle = document.querySelector(`.${classSelector}`);
+ otherToggle?.focus();
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover="tooltip"
+ aria-controls="super-sidebar"
+ :aria-expanded="ariaExpanded"
+ :aria-label="$options.i18n.navigationSidebar"
+ icon="sidebar"
+ category="tertiary"
+ :disabled="isPeek"
+ @click="toggle"
+ />
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index e27acb60372..f311c5242f5 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,19 +1,24 @@
<script>
-import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { highCountTrim } from '~/lib/utils/text_utility';
import logo from '../../../../views/shared/_logo.svg';
-import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
import MergeRequestMenu from './merge_request_menu.vue';
import UserMenu from './user_menu.vue';
+import SuperSidebarToggle from './super_sidebar_toggle.vue';
+import { SEARCH_MODAL_ID } from './global_search/constants';
export default {
// "GitLab Next" is a proper noun, so don't translate "Next"
/* eslint-disable-next-line @gitlab/require-i18n-strings */
NEXT_LABEL: 'Next',
logo,
+ JS_TOGGLE_COLLAPSE_CLASS,
+ SEARCH_MODAL_ID,
components: {
Counter,
CreateMenu,
@@ -21,29 +26,63 @@ export default {
GlButton,
MergeRequestMenu,
UserMenu,
+ SearchModal: () =>
+ import(
+ /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
+ ),
+ SuperSidebarToggle,
},
i18n: {
- collapseSidebar: __('Collapse sidebar'),
createNew: __('Create new...'),
+ homepage: __('Homepage'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
search: __('Search'),
+ searchKbdHelp: sprintf(
+ s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+ ),
todoList: __('To-Do list'),
+ stopImpersonating: __('Stop impersonating'),
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
SafeHtml,
},
- inject: ['rootPath'],
+ inject: ['rootPath', 'isImpersonating'],
props: {
+ hasCollapseButton: {
+ default: true,
+ type: Boolean,
+ required: false,
+ },
sidebarData: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ mrMenuShown: false,
+ todoCount: this.sidebarData.todos_pending_count,
+ };
+ },
+ computed: {
+ formattedTodoCount() {
+ return highCountTrim(this.todoCount);
+ },
+ },
+ mounted() {
+ document.addEventListener('todo:toggle', this.updateTodos);
+ },
+ beforeDestroy() {
+ document.removeEventListener('todo:toggle', this.updateTodos);
+ },
methods: {
- collapseSidebar() {
- toggleSuperSidebarCollapsed(true, true, true);
+ updateTodos(e) {
+ this.todoCount = e.detail.count || 0;
},
},
};
@@ -51,8 +90,15 @@ export default {
<template>
<div class="user-bar">
- <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-2">
- <a :href="rootPath">
+ <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"
+ :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"
@@ -66,54 +112,86 @@ export default {
variant="success"
:href="sidebarData.canary_toggle_com_url"
size="sm"
- >{{ $options.NEXT_LABEL }}</gl-badge
+ class="gl-ml-2"
>
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
<div class="gl-flex-grow-1"></div>
- <gl-button
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar"
- :aria-label="$options.i18n.collapseSidebar"
- icon="sidebar"
- category="tertiary"
- @click="collapseSidebar"
+ <super-sidebar-toggle
+ v-if="hasCollapseButton"
+ :class="$options.JS_TOGGLE_COLLAPSE_CLASS"
+ tooltip-placement="bottom"
+ tooltip-container="super-sidebar"
+ data-testid="super-sidebar-collapse-button"
/>
<create-menu :groups="sidebarData.create_new_menu_groups" />
+
<gl-button
+ id="super-sidebar-search"
+ v-gl-tooltip.bottom.hover.html="$options.i18n.searchKbdHelp"
+ v-gl-modal="$options.SEARCH_MODAL_ID"
+ data-testid="super-sidebar-search-button"
icon="search"
:aria-label="$options.i18n.search"
category="tertiary"
- href="/search"
/>
+ <search-modal />
+
<user-menu :data="sidebarData" />
+
+ <gl-button
+ v-if="isImpersonating"
+ v-gl-tooltip
+ :href="sidebarData.stop_impersonation_path"
+ :title="$options.i18n.stopImpersonating"
+ :aria-label="$options.i18n.stopImpersonating"
+ icon="incognito"
+ variant="confirm"
+ category="tertiary"
+ data-method="delete"
+ data-testid="stop-impersonation-btn"
+ />
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
:count="sidebarData.assigned_open_issues_count"
:href="sidebarData.issues_dashboard_path"
:label="$options.i18n.issues"
+ data-track-action="click_link"
+ data-track-label="issues_link"
+ data-track-property="nav_core_menu"
/>
<merge-request-menu
class="gl-flex-basis-third gl-display-block!"
:items="sidebarData.merge_request_menu"
+ @shown="mrMenuShown = true"
+ @hidden="mrMenuShown = false"
>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests"
+ v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
class="gl-w-full"
icon="merge-request-open"
:count="sidebarData.total_merge_requests_count"
:label="$options.i18n.mergeRequests"
+ data-track-action="click_dropdown"
+ data-track-label="merge_requests_menu"
+ data-track-property="nav_core_menu"
/>
</merge-request-menu>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
- :count="sidebarData.todos_pending_count"
+ :count="formattedTodoCount"
href="/dashboard/todos"
:label="$options.i18n.todoList"
data-qa-selector="todos_shortcut_button"
+ data-track-action="click_link"
+ data-track-label="todos_link"
+ data-track-property="nav_core_menu"
/>
</div>
</div>
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index 34bbb3ce177..c90d1ad9c3e 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -11,13 +11,17 @@ import { s__, __, sprintf } from '~/locale';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import Tracking from '~/tracking';
import PersistentUserCallout from '~/persistent_user_callout';
+import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants';
import UserNameGroup from './user_name_group.vue';
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -211;
+
export default {
- feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533',
+ feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/403059',
i18n: {
newNavigation: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
+ badgeLabel: s__('NorthstarNavigation|Beta'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
},
setStatus: s__('SetStatusModal|Set status'),
@@ -72,6 +76,10 @@ export default {
return {
text: this.$options.i18n.startTrial,
href: this.data.trial.url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'start_trial',
+ },
};
},
editProfileItem() {
@@ -80,6 +88,8 @@ export default {
href: this.data.settings.profile_path,
extraAttrs: {
'data-qa-selector': 'edit_profile_link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_edit_profile',
},
};
},
@@ -87,6 +97,10 @@ export default {
return {
text: this.$options.i18n.preferences,
href: this.data.settings.profile_preferences_path,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_preferences',
+ },
};
},
addBuyPipelineMinutesMenuItem() {
@@ -99,6 +113,8 @@ export default {
href: this.data.pipeline_minutes?.buy_pipeline_minutes_path,
extraAttrs: {
class: 'js-follow-link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'buy_pipeline_minutes',
},
};
},
@@ -106,6 +122,10 @@ export default {
return {
text: this.$options.i18n.gitlabNext,
href: this.data.canary_toggle_com_url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'switch_to_canary',
+ },
};
},
feedbackItem() {
@@ -114,6 +134,8 @@ export default {
href: this.$options.feedbackUrl,
extraAttrs: {
target: '_blank',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'provide_nav_beta_feedback',
},
};
},
@@ -139,9 +161,12 @@ export default {
'data-default-emoji': 'speech_balloon',
};
- if (!this.data.status.customized) {
+ const { busy, customized } = this.data.status;
+
+ if (!busy && !customized) {
return defaultData;
}
+
return {
...defaultData,
'data-current-emoji': this.data.status.emoji,
@@ -164,15 +189,20 @@ export default {
},
methods: {
onShow() {
- this.trackEvents();
- this.initCallout();
+ this.initBuyCIMinsCallout();
+ },
+ closeDropdown() {
+ this.$refs.userDropdown.close();
},
- initCallout() {
+ initBuyCIMinsCallout() {
if (this.showNotificationDot) {
PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el);
}
},
- trackEvents() {
+ /* We're not sure this event is tracked by anyone
+ whether it stays will depend on the outcome of this discussion:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */
+ trackBuyCIMins() {
if (this.addBuyPipelineMinutesMenuItem) {
const {
'track-action': trackAction,
@@ -182,6 +212,22 @@ export default {
this.track(trackAction, { label, property });
}
},
+ trackSignOut() {
+ this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], {
+ label: 'user_sign_out',
+ property: USER_MENU_TRACKING_DEFAULTS['data-track-property'],
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
},
};
</script>
@@ -189,7 +235,8 @@ export default {
<template>
<div>
<gl-disclosure-dropdown
- placement="right"
+ ref="userDropdown"
+ :popper-options="$options.popperOptions"
data-testid="user-dropdown"
data-qa-selector="user_menu"
@shown="onShow"
@@ -220,6 +267,7 @@ export default {
v-if="data.status.can_update"
:item="statusItem"
data-testid="status-item"
+ @action="closeDropdown"
/>
<gl-disclosure-dropdown-item
@@ -243,6 +291,7 @@ export default {
:item="buyPipelineMinutesItem"
v-bind="buyPipelineMinutesCalloutData"
data-testid="buy-pipeline-minutes-item"
+ @action="trackBuyCIMins"
>
<template #list-item>
<span class="gl-display-flex gl-flex-direction-column">
@@ -279,6 +328,7 @@ export default {
bordered
:group="signOutGroup"
data-testid="sign-out-group"
+ @action="trackSignOut"
/>
</gl-disclosure-dropdown>
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
index 2489f462122..57958a03edd 100644
--- a/app/assets/javascripts/super_sidebar/components/user_name_group.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -1,16 +1,22 @@
<script>
-import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui';
+import {
+ GlBadge,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlTooltip,
+} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-
import { s__ } from '~/locale';
+import { USER_MENU_TRACKING_DEFAULTS } from '../constants';
export default {
i18n: {
user: {
- busy: s__('UserProfile|(Busy)'),
+ busy: s__('UserProfile|Busy'),
},
},
components: {
+ GlBadge,
GlDisclosureDropdownGroup,
GlDisclosureDropdownItem,
GlTooltip,
@@ -31,7 +37,13 @@ export default {
};
if (this.user.has_link_to_profile) {
item.href = this.user.link_to_profile;
+
+ item.extraAttrs = {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_profile',
+ };
}
+
return item;
},
},
@@ -47,9 +59,9 @@ export default {
<span class="gl-font-weight-bold">
{{ user.name }}
</span>
- <span v-if="user.status.busy" class="gl-text-gray-500">{{
- $options.i18n.user.busy
- }}</span>
+ <gl-badge v-if="user.status.busy" size="sm" variant="warning">
+ {{ $options.i18n.user.busy }}
+ </gl-badge>
</span>
<span class="gl-text-gray-400">@{{ user.username }}</span>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
index acc03bc48c7..4f5b027c138 100644
--- a/app/assets/javascripts/super_sidebar/constants.js
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -5,10 +5,44 @@
import Vue from 'vue';
export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount';
+export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse';
+export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand';
export const portalState = Vue.observable({
ready: false,
});
+export const sidebarState = Vue.observable({
+ contextSwitcherOpen: false,
+ isCollapsed: false,
+ isPeek: false,
+ openPeekTimer: null,
+ closePeekTimer: null,
+});
+
export const MAX_FREQUENT_PROJECTS_COUNT = 5;
export const MAX_FREQUENT_GROUPS_COUNT = 3;
+
+export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200;
+export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500;
+
+export const TRACKING_UNKNOWN_ID = 'item_without_id';
+export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown';
+export const CLICK_MENU_ITEM_ACTION = 'click_menu_item';
+
+export const PANELS_WITH_PINS = ['group', 'project'];
+
+export const USER_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const HELP_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_help_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded';
+export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10;
+
+export const DROPDOWN_Y_OFFSET = 4;
diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js
deleted file mode 100644
index 5e5ad97eb68..00000000000
--- a/app/assets/javascripts/super_sidebar/mock_data.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { s__ } from '~/locale';
-
-export const contextSwitcherItems = {
- yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
- explore: { title: s__('Navigation|Explore'), link: '/explore', icon: 'compass' },
- recentProjects: [
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Orange',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Lemon',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Coconut',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64',
- },
- ],
- recentGroups: [
- {
- title: 'Developer Evangelism at GitLab',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64',
- },
- {
- title: 'security-products',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64',
- },
- {
- title: 'Tanuki-Workshops',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64',
- },
- ],
-};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index 4395cc2f5f0..fdd29a1719c 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,12 +1,16 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import { initStatusTriggers } from '../header';
+import { JS_TOGGLE_EXPAND_CLASS } from './constants';
+import createStore from './components/global_search/store';
import {
bindSuperSidebarCollapsedEvents,
initSuperSidebarCollapsedState,
} from './super_sidebar_collapsed_state_manager';
import SuperSidebar from './components/super_sidebar.vue';
+import SuperSidebarToggle from './components/super_sidebar_toggle.vue';
Vue.use(VueApollo);
@@ -23,6 +27,11 @@ export const initSuperSidebar = () => {
initSuperSidebarCollapsedState();
const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
+ const sidebarData = JSON.parse(sidebar);
+ const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+
+ const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
+ const isImpersonating = parseBoolean(sidebarData.is_impersonating);
return new Vue({
el,
@@ -31,15 +40,48 @@ export const initSuperSidebar = () => {
provide: {
rootPath,
toggleNewNavEndpoint,
+ isImpersonating,
},
+ store: createStore({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search: '',
+ }),
render(h) {
return h(SuperSidebar, {
props: {
- sidebarData: JSON.parse(sidebar),
+ sidebarData,
},
});
},
});
};
+/**
+ * Guard against multiple instantiations, since the js-* class is persisted
+ * in the Vue component.
+ */
+let toggleInstantiated = false;
+
+export const initSuperSidebarToggle = () => {
+ const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`);
+
+ if (!el || toggleInstantiated) return false;
+
+ toggleInstantiated = true;
+
+ return new Vue({
+ el,
+ name: 'SuperSidebarToggleRoot',
+ render(h) {
+ // Copy classes from HAML-defined button to ensure same positioning,
+ // including JS_TOGGLE_EXPAND_CLASS.
+ return h(SuperSidebarToggle, { class: el.className });
+ },
+ });
+};
+
requestIdleCallback(initStatusTriggers);
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 549c6c17e44..17e07146678 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
@@ -1,14 +1,15 @@
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { debounce } from 'lodash';
import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { sidebarState } from './constants';
export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed';
export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed';
export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10;
+export const SIDEBAR_TRANSITION_DURATION = 200;
export const findPage = () => document.querySelector('.page-with-super-sidebar');
export const findSidebar = () => document.querySelector('.super-sidebar');
-export const findToggles = () => document.querySelectorAll('.js-super-sidebar-toggle');
export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS);
@@ -19,15 +20,15 @@ export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true';
-export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => {
- const sidebar = findSidebar();
- sidebar.ariaHidden = collapsed;
- sidebar.inert = collapsed;
-
- if (!collapsed && isUserAction) sidebar.focus();
+export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
+ clearTimeout(sidebarState.openPeekTimer);
+ clearTimeout(sidebarState.closePeekTimer);
findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
+ sidebarState.isPeek = false;
+ sidebarState.isCollapsed = collapsed;
+
if (saveCookie && isDesktopBreakpoint()) {
setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
@@ -41,11 +42,5 @@ export const initSuperSidebarCollapsedState = () => {
};
export const bindSuperSidebarCollapsedEvents = () => {
- findToggles().forEach((elem) => {
- elem.addEventListener('click', () => {
- toggleSuperSidebarCollapsed(!isCollapsed(), true, true);
- });
- });
-
window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100));
};
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 065e1080897..d79252f6bb7 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
@@ -14,6 +12,7 @@ export default function syntaxHighlight($els = null) {
if (!$els || $els.length === 0) return;
const els = $els.get ? $els.get() : $els;
+ // eslint-disable-next-line consistent-return
const handler = (el) => {
if (el.classList === undefined) {
return el;
diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
new file mode 100644
index 00000000000..3ba0ab29530
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
@@ -0,0 +1,69 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query timeTrackingReport(
+ $startDate: Time
+ $endDate: Time
+ $projectId: ProjectID
+ $groupId: GroupID
+ $username: String
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ timelogs(
+ startDate: $startDate
+ endDate: $endDate
+ projectId: $projectId
+ groupId: $groupId
+ username: $username
+ first: $first
+ last: $last
+ after: $after
+ before: $before
+ sort: SPENT_AT_DESC
+ ) {
+ count
+ totalSpentTime
+ nodes {
+ id
+ project {
+ id
+ webUrl
+ fullPath
+ nameWithNamespace
+ }
+ timeSpent
+ user {
+ id
+ name
+ username
+ avatarUrl
+ webPath
+ }
+ spentAt
+ note {
+ id
+ body
+ }
+ summary
+ issue {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ mergeRequest {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
new file mode 100644
index 00000000000..33b0ac4b58e
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { IssuableStatusText } from '~/issues/constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ timelog: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ subject() {
+ const { issue, mergeRequest } = this.timelog;
+ return issue || mergeRequest;
+ },
+ issuableStatus() {
+ return IssuableStatusText[this.subject.state];
+ },
+ issuableFullReference() {
+ return this.timelog.project.fullPath + this.subject.reference;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!">
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold"
+ data-testid="title-container"
+ >
+ {{ subject.title }}
+ </gl-link>
+ <span>
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900"
+ data-testid="reference-container"
+ >
+ {{ issuableFullReference }}
+ </gl-link>
+ • <span data-testid="state-container">{{ issuableStatus }}</span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
new file mode 100644
index 00000000000..2069e4a6722
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
@@ -0,0 +1,229 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+} from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { formatTimeSpent } from '~/lib/utils/datetime_utility';
+import { s__ } from '~/locale';
+import getTimelogsQuery from './queries/get_timelogs.query.graphql';
+import TimelogsTable from './timelogs_table.vue';
+
+const ENTRIES_PER_PAGE = 20;
+
+// Define initial dates to current date and time
+const INITIAL_TO_DATE = new Date();
+const INITIAL_FROM_DATE = new Date();
+
+// Set the initial 'from' date to 30 days before the current date
+INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30);
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+ TimelogsTable,
+ },
+ props: {
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectId: null,
+ groupId: null,
+ username: null,
+ timeSpentFrom: INITIAL_FROM_DATE,
+ timeSpentTo: INITIAL_TO_DATE,
+ cursor: {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ },
+ queryVariables: {
+ startDate: INITIAL_FROM_DATE,
+ endDate: INITIAL_TO_DATE,
+ projectId: null,
+ groupId: null,
+ username: null,
+ },
+ pageInfo: {},
+ report: [],
+ totalSpentTime: 0,
+ };
+ },
+ apollo: {
+ report: {
+ query: getTimelogsQuery,
+ variables() {
+ return {
+ ...this.queryVariables,
+ ...this.cursor,
+ };
+ },
+ update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) {
+ this.pageInfo = pageInfo;
+ this.totalSpentTime = totalSpentTime;
+ return nodes;
+ },
+ error(error) {
+ createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') });
+ Sentry.captureException(error);
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.report.loading;
+ },
+ showPagination() {
+ return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage;
+ },
+ formattedTotalSpentTime() {
+ return formatTimeSpent(this.totalSpentTime, this.limitToHours);
+ },
+ },
+ methods: {
+ nullIfBlank(value) {
+ return value === '' ? null : value;
+ },
+ runReport() {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ };
+
+ this.queryVariables = {
+ startDate: this.nullIfBlank(this.timeSpentFrom),
+ endDate: this.nullIfBlank(this.timeSpentTo),
+ projectId: this.nullIfBlank(this.projectId),
+ groupId: this.nullIfBlank(this.groupId),
+ username: this.nullIfBlank(this.username),
+ };
+ },
+ nextPage(item) {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: item,
+ last: null,
+ before: null,
+ };
+ },
+ prevPage(item) {
+ this.cursor = {
+ first: null,
+ after: null,
+ last: ENTRIES_PER_PAGE,
+ before: item,
+ };
+ },
+ clearTimeSpentFromDate() {
+ this.timeSpentFrom = null;
+ },
+ clearTimeSpentToDate() {
+ this.timeSpentTo = null;
+ },
+ },
+ i18n: {
+ username: s__('TimeTrackingReport|Username'),
+ from: s__('TimeTrackingReport|From'),
+ to: s__('TimeTrackingReport|To'),
+ runReport: s__('TimeTrackingReport|Run report'),
+ totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5">
+ <form
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
+ @submit.prevent="runReport"
+ >
+ <gl-form-group
+ :label="$options.i18n.username"
+ label-for="timelog-form-username"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-form-input
+ id="timelog-form-username"
+ v-model="username"
+ data-testid="form-username"
+ class="gl-w-full"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-from"
+ :label="$options.i18n.from"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentFrom"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-from-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentFromDate"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-to"
+ :label="$options.i18n.to"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentTo"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-to-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentToDate"
+ />
+ </gl-form-group>
+ <gl-button
+ class="gl-align-self-end gl-w-full gl-md-w-auto"
+ variant="confirm"
+ @click="runReport"
+ >{{ $options.i18n.runReport }}</gl-button
+ >
+ </form>
+ <div
+ v-if="!isLoading"
+ data-testid="table-container"
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span>
+ <span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span>
+ </div>
+
+ <timelogs-table :limit-to-hours="limitToHours" :entries="report" />
+
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ class="gl-mt-3 gl-align-self-center"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
+ <gl-loading-icon v-else size="lg" class="gl-mt-5" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
new file mode 100644
index 00000000000..b2efb44f56f
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { s__ } from '~/locale';
+import TimelogSourceCell from './timelog_source_cell.vue';
+
+const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
+
+export default {
+ components: {
+ GlTable,
+ UserAvatarLink,
+ TimelogSourceCell,
+ },
+ props: {
+ entries: {
+ type: Array,
+ required: true,
+ },
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ fields: [
+ {
+ key: 'spentAt',
+ label: s__('TimeTrackingReport|Spent at'),
+ tdClass: 'gl-md-w-30',
+ },
+ {
+ key: 'source',
+ label: s__('TimeTrackingReport|Source'),
+ },
+ {
+ key: 'user',
+ label: s__('TimeTrackingReport|User'),
+ tdClass: 'gl-md-w-20',
+ },
+ {
+ key: 'timeSpent',
+ label: s__('TimeTrackingReport|Time spent'),
+ tdClass: 'gl-md-w-15',
+ },
+ {
+ key: 'summary',
+ label: s__('TimeTrackingReport|Summary'),
+ },
+ ],
+ };
+ },
+ methods: {
+ formatDate(date) {
+ return formatDate(date, TIME_DATE_FORMAT);
+ },
+ formatTimeSpent(seconds) {
+ return formatTimeSpent(seconds, this.limitToHours);
+ },
+ extractTimelogSummary(timelog) {
+ const { note, summary } = timelog;
+ return note?.body || summary;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="entries" :fields="fields" stacked="md" show-empty>
+ <template #cell(spentAt)="{ item: { spentAt } }">
+ <div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div>
+ </template>
+
+ <template #cell(source)="{ item }">
+ <timelog-source-cell :timelog="item" />
+ </template>
+
+ <template #cell(user)="{ item: { user } }">
+ <user-avatar-link
+ class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900"
+ :link-href="user.webPath"
+ :img-src="user.avatarUrl"
+ :img-size="16"
+ :img-alt="user.name"
+ :tooltip-text="user.name"
+ :username="user.name"
+ />
+ </template>
+
+ <template #cell(timeSpent)="{ item: { timeSpent } }">
+ <div data-testid="time-spent-container" class="gl-text-left!">
+ {{ formatTimeSpent(timeSpent) }}
+ </div>
+ </template>
+
+ <template #cell(summary)="{ item }">
+ <div data-testid="summary-container" class="gl-text-left!">
+ {{ extractTimelogSummary(item) }}
+ </div>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js
new file mode 100644
index 00000000000..9cff01799d9
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import TimelogsApp from './components/timelogs_app.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-timelogs-app');
+ if (!el) {
+ return false;
+ }
+
+ const { limitToHours } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(TimelogsApp, {
+ props: {
+ limitToHours: parseBoolean(limitToHours),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 2593fbe6ed1..7f4c7a91b20 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -5,14 +5,11 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
hostname: window.location.hostname,
cookieDomain: window.location.hostname,
appId: '',
- userFingerprint: false,
respectDoNotTrack: true,
- forceSecureTracker: true,
eventMethod: 'post',
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
- pageUnloadTimer: 10,
formTrackingConfig: {
forms: { allow: [] },
fields: { allow: [] },
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
index 5daeaf1d85b..89d90cf89be 100644
--- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -26,7 +26,14 @@ export function dispatchSnowplowEvent(
}
try {
- window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+ window.snowplow('trackStructEvent', {
+ category,
+ action,
+ label,
+ property,
+ value,
+ context: contexts,
+ });
return true;
} catch (error) {
Sentry.captureException(error);
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index d60eb37a9a2..472ce3c5bbf 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -7,7 +7,7 @@ export { Tracking as default };
/**
* Tracker initialization as defined in:
- * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/.
+ * https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/initialization-options/.
* It also dispatches any event emitted before its execution.
*
* @returns {undefined}
@@ -42,13 +42,19 @@ export function initDefaultTrackers() {
// must be before initializing the trackers
Tracking.setAnonymousUrls();
- window.snowplow('enableActivityTracking', 30, 30);
+ window.snowplow('enableActivityTracking', {
+ minimumVisitLength: 30,
+ heartbeatDelay: 30,
+ });
// must be after enableActivityTracking
const standardContext = getStandardContext();
const experimentContexts = getAllExperimentContexts();
// To not expose personal identifying information, the page title is hardcoded as `GitLab`
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243
- window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]);
+ window.snowplow('trackPageView', {
+ title: 'GitLab',
+ context: [standardContext, ...experimentContexts],
+ });
window.snowplow('setDocumentTitle', 'GitLab');
if (window.snowplowOptions.formTracking) {
diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js
index 85f4979e752..b69b1714952 100644
--- a/app/assets/javascripts/tracking/tracker.js
+++ b/app/assets/javascripts/tracking/tracker.js
@@ -207,14 +207,18 @@ export const Tracker = {
const mappedConfig = {};
if (config.forms) {
- mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
+ mappedConfig.forms = renameKey(config.forms, 'allow', 'allowlist');
}
if (config.fields) {
- mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
+ mappedConfig.fields = renameKey(config.fields, 'allow', 'allowlist');
}
- const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
+ const enabler = () =>
+ window.snowplow('enableFormTracking', {
+ options: mappedConfig,
+ context: userProvidedContexts,
+ });
if (document.readyState === 'complete') {
enabler();
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 2b97886e650..beff3b4c0c3 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
@@ -9,9 +9,6 @@ import {
PROJECT_TABLE_LABEL_USAGE,
containerRegistryId,
containerRegistryPopoverId,
- uploadsId,
- uploadsPopoverId,
- uploadsPopoverContent,
} from '../constants';
import { descendingStorageUsageSort } from '../utils';
import StorageTypeIcon from './storage_type_icon.vue';
@@ -40,10 +37,6 @@ export default {
popoverId: containerRegistryPopoverId,
popoverContent: this.containerRegistryPopoverContent,
},
- [uploadsId]: {
- popoverId: uploadsPopoverId,
- popoverContent: this.$options.i18n.uploadsPopoverContent,
- },
};
return this.storageTypes
@@ -77,9 +70,6 @@ export default {
thClass: thWidthPercent(10),
},
],
- i18n: {
- uploadsPopoverContent,
- },
};
</script>
<template>
@@ -100,7 +90,7 @@ export default {
:aria-label="helpLinkAriaLabel(item.storageType.name)"
:data-testid="`${item.storageType.id}-help-link`"
>
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</gl-link>
</p>
<p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
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 bc7cd42df1e..5142c2c0915 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
@@ -16,7 +16,6 @@ export default {
const storageTypeIconMap = {
lfsObjectsSize: 'doc-image',
snippetsSize: 'snippet',
- uploadsSize: 'upload',
repositorySize: 'infrastructure-registry',
packagesSize: 'package',
};
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 7e001685060..e9683924ff8 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -1,17 +1,10 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
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 {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
mixins: [glFeatureFlagMixin()],
props: {
rootStorageStatistics: {
@@ -35,9 +28,7 @@ export default {
storageSize,
wikiSize,
snippetsSize,
- uploadsSize,
} = this.rootStorageStatistics;
- const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
if (storageSize === 0) {
return null;
@@ -70,9 +61,15 @@ export default {
},
{
id: 'buildArtifactsSize',
- style: this.usageStyle(this.barRatio(artifactsSize)),
- class: 'gl-bg-data-viz-green-600',
- size: artifactsSize,
+ style: this.usageStyle(this.barRatio(buildArtifactsSize)),
+ class: 'gl-bg-data-viz-green-500',
+ size: buildArtifactsSize,
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ style: this.usageStyle(this.barRatio(pipelineArtifactsSize)),
+ class: 'gl-bg-data-viz-green-800',
+ size: pipelineArtifactsSize,
},
{
id: 'wikiSize',
@@ -86,12 +83,6 @@ export default {
class: 'gl-bg-data-viz-orange-800',
size: snippetsSize,
},
- {
- id: 'uploadsSize',
- style: this.usageStyle(this.barRatio(uploadsSize)),
- class: 'gl-bg-data-viz-aqua-700',
- size: uploadsSize,
- },
]
.filter((data) => data.size !== 0)
.sort(descendingStorageUsageSort('size'))
@@ -99,11 +90,10 @@ export default {
const storageTypeExtraData = PROJECT_STORAGE_TYPES.find(
(type) => storageType.id === type.id,
);
- const { name, tooltip } = storageTypeExtraData || {};
+ const name = storageTypeExtraData?.name;
return {
name,
- tooltip,
...storageType,
};
});
@@ -155,15 +145,6 @@ export default {
<span class="gl-text-gray-500 gl-font-sm">
{{ formatSize(storageType.size) }}
</span>
- <span
- v-if="storageType.tooltip"
- v-gl-tooltip
- :title="storageType.tooltip"
- :aria-label="storageType.tooltip"
- class="gl-ml-2"
- >
- <gl-icon name="question" :size="12" />
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index bd8cd372ecf..8e3eaff4496 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -8,7 +8,7 @@ export const LEARN_MORE_LABEL = __('Learn more.');
export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
export const TOTAL_USAGE_SUBTITLE = s__(
- 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
+ 'UsageQuota|Includes artifacts, repositories, wiki, and other items.',
);
export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
@@ -20,11 +20,6 @@ export const projectContainerRegistryPopoverContent = s__(
export const containerRegistryId = 'containerRegistrySize';
export const containerRegistryPopoverId = 'container-registry-popover';
-export const uploadsId = 'uploadsSize';
-export const uploadsPopoverId = 'uploads-popover';
-export const uploadsPopoverContent = s__(
- 'NamespaceStorage|Uploads are not counted in namespace storage quotas.',
-);
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
@@ -32,45 +27,44 @@ export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
export const PROJECT_STORAGE_TYPES = [
{
id: 'containerRegistrySize',
- name: s__('UsageQuota|Container Registry'),
+ name: __('Container Registry'),
description: s__(
'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
),
},
{
id: 'buildArtifactsSize',
- name: s__('UsageQuota|Artifacts'),
- description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
- tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
+ name: __('Job artifacts'),
+ description: s__('UsageQuota|Job artifacts created by CI/CD.'),
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ name: __('Pipeline artifacts'),
+ description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'),
},
{
id: 'lfsObjectsSize',
- name: s__('UsageQuota|LFS storage'),
+ name: __('LFS'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
id: 'packagesSize',
- name: s__('UsageQuota|Packages'),
+ name: __('Packages'),
description: s__('UsageQuota|Code packages and container images.'),
},
{
id: 'repositorySize',
- name: s__('UsageQuota|Repository'),
+ name: __('Repository'),
description: s__('UsageQuota|Git repository.'),
},
{
id: 'snippetsSize',
- name: s__('UsageQuota|Snippets'),
+ name: __('Snippets'),
description: s__('UsageQuota|Shared bits of code and text.'),
},
{
- id: 'uploadsSize',
- name: s__('UsageQuota|Uploads'),
- description: s__('UsageQuota|File attachments and smaller design graphics.'),
- },
- {
id: 'wikiSize',
- name: s__('UsageQuota|Wiki'),
+ name: __('Wiki'),
description: s__('UsageQuota|Wiki content.'),
},
];
@@ -86,6 +80,9 @@ export const projectHelpPaths = {
buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
anchor: 'when-job-artifacts-are-deleted',
}),
+ pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', {
+ anchor: 'when-pipeline-artifacts-are-deleted',
+ }),
packages: helpPagePath('user/packages/package_registry/index.md', {
anchor: 'reduce-storage-usage',
}),
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 6637e5e0865..d254f576219 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
@@ -10,7 +10,6 @@ query getProjectStorageStatistics($fullPath: ID!) {
repositorySize
snippetsSize
storageSize
- uploadsSize
wikiSize
}
}
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index a401a9bbf2f..66e54b59187 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -467,6 +467,8 @@ function UsersSelect(currentUser, els, options = {}) {
// display:block overrides the hide-collapse rule
$value.css('display', '');
}
+
+ $('.dropdown-input-field', $block).val('');
},
multiSelect: $dropdown.hasClass('js-multiselect'),
inputMeta: $dropdown.data('inputMeta'),
@@ -694,17 +696,18 @@ UsersSelect.prototype.renderRow = function (
: '';
const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : '';
- const name =
+ const busyBadge =
user?.availability && isUserBusy(user.availability)
- ? sprintf(__('%{name} (Busy)'), { name: user.name })
- : user.name;
+ ? `<span class="badge badge-warning badge-pill gl-badge sm">${__('Busy')}</span>`
+ : '';
return `
<li data-user-id=${user.id} ${dataUserSuggested}>
<a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
- ${escape(name)}
+ ${escape(user.name)}
+ ${busyBadge}
</strong>
${
username
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 77736fb6ef5..e30982985b3 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
@@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
[VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
[VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
+
+export const GROUP_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The group and any public projects can be viewed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The project can be accessed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+ ),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index f377a185879..5090081d281 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -1,6 +1,7 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -49,7 +50,7 @@ export default {
},
computed: {
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
targetBranchEscaped() {
return escape(this.targetBranch);
@@ -67,7 +68,7 @@ export default {
);
},
message() {
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
} else if (this.isMerged) {
return s__(
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 74922dd922c..25cf5335fb5 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
@@ -1,15 +1,15 @@
<script>
import { GlButton, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/alert';
+import { STATUS_MERGED } from '~/issues/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { s__, __ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
-import MrWidgetContainer from '../mr_widget_container.vue';
-import MrWidgetIcon from '../mr_widget_icon.vue';
+import StateContainer from '../state_container.vue';
import { INVALID_RULES_DOCS_PATH } from '../../constants';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
@@ -18,14 +18,17 @@ import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
export default {
name: 'MRWidgetApprovals',
components: {
- MrWidgetContainer,
- MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
+ StateContainer,
GlButton,
GlSprintf,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
+ provide: {
+ expandDetailsTooltip: __('Expand eligible approvers'),
+ collapseDetailsTooltip: __('Collapse eligible approvers'),
+ },
props: {
mr: {
type: Object,
@@ -55,6 +58,11 @@ export default {
required: false,
default: false,
},
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -76,13 +84,22 @@ export default {
return Boolean(this.action);
},
invalidRules() {
- return this.approvals.approvalState?.invalidApproversRules || [];
+ return this.approvals.approvalState?.rules?.filter((rule) => rule.invalid) || [];
+ },
+ invalidApprovedRules() {
+ return this.invalidRules.filter((rule) => rule.allowMergeWhenInvalid);
+ },
+ invalidFailedRules() {
+ return this.invalidRules.filter((rule) => !rule.allowMergeWhenInvalid);
},
hasInvalidRules() {
return this.mr.mergeRequestApproversAvailable && this.invalidRules.length;
},
- invalidRulesText() {
- return this.invalidRules.length;
+ hasInvalidApprovedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length;
+ },
+ hasInvalidFailedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.length;
},
approvedBy() {
return this.approvals.approvedBy?.nodes || [];
@@ -99,7 +116,7 @@ export default {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
},
showUnapprove() {
- return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
+ return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED;
},
approvalText() {
return this.isApproved && this.approvedBy.length > 0
@@ -125,11 +142,29 @@ export default {
return null;
},
- pluralizedRuleText() {
- return this.invalidRules.length > 1
+ pluralizedApprovedRuleText() {
+ return this.invalidApprovedRules.length > 1
? this.$options.i18n.invalidRulesPlural
: this.$options.i18n.invalidRuleSingular;
},
+ pluralizedFailedRuleText() {
+ return this.invalidFailedRules.length > 1
+ ? this.$options.i18n.invalidFailedRulesPlural
+ : this.$options.i18n.invalidFailedRuleSingular;
+ },
+ pluralizedRuleText() {
+ return [
+ this.hasInvalidFailedRules
+ ? sprintf(this.pluralizedFailedRuleText, { rules: this.invalidFailedRules.length })
+ : null,
+ this.hasInvalidApprovedRules
+ ? sprintf(this.pluralizedApprovedRuleText, { rules: this.invalidApprovedRules.length })
+ : null,
+ ]
+ .filter((text) => Boolean(text))
+ .join(', ')
+ .concat('.');
+ },
},
methods: {
approve() {
@@ -197,21 +232,30 @@ export default {
FETCH_LOADING,
linkToInvalidRules: INVALID_RULES_DOCS_PATH,
i18n: {
- invalidRuleSingular: s__(
- 'mrWidget|%{rules} invalid rule has been approved automatically, as no one can approve it.',
+ invalidRuleSingular: s__('mrWidget|%{rules} invalid rule has been approved automatically'),
+ invalidRulesPlural: s__('mrWidget|%{rules} invalid rules have been approved automatically'),
+ invalidFailedRuleSingular: s__(
+ "mrWidget|%{dangerStart}%{rules} rule can't be approved%{dangerEnd}",
),
- invalidRulesPlural: s__(
- 'mrWidget|%{rules} invalid rules have been approved automatically, as no one can approve them.',
+ invalidFailedRulesPlural: s__(
+ "mrWidget|%{dangerStart}%{rules} rules can't be approved%{dangerEnd}",
),
learnMore: __('Learn more.'),
},
};
</script>
<template>
- <mr-widget-container>
- <div class="js-mr-approvals d-flex align-items-start align-items-md-center">
- <mr-widget-icon name="approval" />
- <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div>
+ <div class="js-mr-approvals mr-section-container mr-widget-workflow">
+ <state-container
+ :is-loading="$apollo.queries.approvals.loading"
+ :mr="mr"
+ status="approval"
+ is-collapsible
+ collapse-on-desktop
+ :collapsed="collapsed"
+ @toggle="() => $emit('toggle')"
+ >
+ <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template>
<template v-else>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
@@ -220,7 +264,7 @@ export default {
:variant="action.variant"
:category="action.category"
:loading="isApproving"
- class="gl-mr-5"
+ class="gl-mr-3"
data-qa-selector="approve_button"
@click="action.action"
>
@@ -234,12 +278,15 @@ export default {
<approvals-summary
v-else
:approval-state="approvals"
+ :disable-committers-approval="disableCommittersApproval"
:multiple-approval-rules-available="mr.multipleApprovalRulesAvailable"
/>
</div>
<div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules">
<gl-sprintf :message="pluralizedRuleText">
- <template #rules>{{ invalidRulesText }}</template>
+ <template #danger="{ content }">
+ <span class="gl-font-weight-bold text-danger">{{ content }}</span>
+ </template>
</gl-sprintf>
</div>
</div>
@@ -249,9 +296,7 @@ export default {
:has-approval-auth-error="hasApprovalAuthError"
></slot>
</template>
- </div>
- <template #footer>
- <slot name="footer"></slot>
- </template>
- </mr-widget-container>
+ </state-container>
+ <slot name="footer"></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index 2af033bb80f..650fa798db6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink, GlPopover } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -12,6 +13,8 @@ import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/ma
export default {
components: {
+ GlLink,
+ GlPopover,
UserAvatarList,
},
props: {
@@ -24,6 +27,11 @@ export default {
type: Object,
required: true,
},
+ disableCommittersApproval: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
approvers() {
@@ -101,6 +109,13 @@ export default {
(approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId,
);
},
+ currentUserHasCommitted() {
+ if (!this.currentUserId) return false;
+
+ return this.approvalState.committers?.nodes?.some(
+ (user) => getIdFromGraphQLId(user.id) === this.currentUserId,
+ );
+ },
currentUserId() {
return gon.current_user_id;
},
@@ -115,10 +130,18 @@ export default {
<span v-if="approvalLeftMessage">{{ message }}</span>
<span v-else class="gl-font-weight-bold">{{ message }}</span>
<user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
+ class="gl-display-inline-flex gl-vertical-align-middle"
:img-size="24"
:items="approvers"
/>
</template>
+ <template v-if="disableCommittersApproval && currentUserHasCommitted">
+ <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{
+ __("Why can't I approve?")
+ }}</gl-link>
+ <gl-popover target="cant-approve-popover">
+ {{ __("You can't approve because you added one or more commits to this merge request.") }}
+ </gl-popover>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index f9d0986d60d..1e5f91e12cf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -60,7 +60,7 @@ export default {
>
<div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
<div class="gl-display-flex gl-m-auto gl-translate-y-n50">
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index e3f87c08ad4..4f8f8d6cb58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -6,86 +6,6 @@ import {
TELEMETRY_WIDGET_FULL_REPORT_CLICKED,
} from '../../constants';
-/*
- * Additional events to send beyond the defaults for certain widget extensions
- */
-const nonStandardEvents = {
- codeQuality: {
- uniqueUser: {
- expand: ['i_testing_code_quality_widget_total'],
- },
- counter: {},
- },
- terraform: {
- uniqueUser: {
- expand: ['i_testing_terraform_widget_total'],
- },
- counter: {},
- },
- issues: {
- uniqueUser: {
- expand: ['i_testing_issues_widget_total'],
- },
- counter: {},
- },
- testSummary: {
- uniqueUser: {
- expand: ['i_testing_summary_widget_total'],
- },
- counter: {},
- },
- metrics: {
- uniqueUser: {
- expand: ['i_testing_metrics_report_widget_total'],
- },
- counter: {},
- },
- browserPerformance: {
- uniqueUser: {
- expand: ['i_testing_web_performance_widget_total'],
- },
- counter: {},
- },
- licenseCompliance: {
- uniqueUser: {
- expand: ['i_testing_license_compliance_widget_total'],
- },
- counter: {},
- },
- loadPerformance: {
- uniqueUser: {
- expand: ['i_testing_load_performance_widget_total'],
- },
- counter: {},
- },
- statusChecks: {
- uniqueUser: {
- expand: ['i_testing_status_checks_widget'],
- },
- counter: {},
- },
-};
-
-function combineDeepArray(path, ...objects) {
- const parts = path.split('.');
- const allEntries = objects.reduce((entries, currentObject) => {
- let expandedEntries = entries;
- let traversed = currentObject;
-
- parts.forEach((part) => {
- traversed = traversed?.[part];
- });
-
- if (traversed) {
- expandedEntries = [...entries, ...traversed];
- }
-
- return expandedEntries;
- }, []);
-
- return Array.from(new Set(allEntries));
-}
-
function simplifyWidgetName(componentName) {
const noWidget = componentName.replace(/^Widget/, '');
@@ -166,7 +86,6 @@ function defaultBehaviorEvents({ bus, config }) {
function baseTelemetry(componentName) {
const simpleExtensionName = simplifyWidgetName(componentName);
- const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {};
/*
* Telemetry config format is:
* {
@@ -179,7 +98,7 @@ function baseTelemetry(componentName) {
* - uniqueUser is sent to RedisHLL
* - counter is sent to a regular Redis counter
*/
- const defaultTelemetry = {
+ return {
uniqueUser: {
view: [`${baseRedisEventName(simpleExtensionName)}_view`],
expand: [`${baseRedisEventName(simpleExtensionName)}_expand`],
@@ -191,27 +110,6 @@ function baseTelemetry(componentName) {
clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`],
},
};
-
- return {
- uniqueUser: {
- view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'uniqueUser.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- counter: {
- view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'counter.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- };
}
export function createTelemetryHub(componentName) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 20284c4a3d8..26527361b2e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { escapeShellString } from '~/lib/utils/text_utility';
@@ -87,11 +86,11 @@ export default {
const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
return this.isFork
- ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD`
- : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` // eslint-disable-line @gitlab/require-i18n-strings
+ : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
mergeInfo2() {
- return `git push origin ${this.escapedSourceBranch}`;
+ return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
escapedForkBranch() {
return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 2dec95c3fda..4e16b92fc05 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -173,7 +173,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="sm" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
@@ -187,7 +187,7 @@ export default {
class="gl-display-flex gl-align-items-center gl-ml-2"
>
<gl-icon
- name="question"
+ name="question-o"
:aria-label="__('Link to go to GitLab pipeline documentation')"
/>
</gl-link>
@@ -251,7 +251,7 @@ export default {
</span>
{{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion">
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</span>
<gl-tooltip
:target="() => $refs.pipelineCoverageQuestion"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 1fd1e264c25..5d75f1d27b1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, n__ } from '~/locale';
@@ -30,10 +31,10 @@ export default {
},
computed: {
closesText() {
- if (this.state === 'merged') {
+ if (this.state === STATUS_MERGED) {
return s__('mrWidget|Closed');
}
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidget|Did not close');
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 3239285e53e..ea3f324b8f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './extensions/status_icon.vue';
export default {
@@ -14,22 +15,24 @@ export default {
},
},
computed: {
+ isClosed() {
+ return this.status === STATUS_CLOSED;
+ },
isLoading() {
return this.status === 'loading';
},
+ isMerged() {
+ return this.status === STATUS_MERGED;
+ },
},
};
</script>
<template>
- <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3">
+ <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3">
<div class="gl-display-flex gl-m-auto">
- <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" />
- <gl-icon
- v-else-if="status === 'closed'"
- name="merge-request-close"
- :size="16"
- class="gl-text-red-500"
- />
+ <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" />
+ <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" />
+ <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" />
<status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" />
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
index 3e79c49994f..dd899701de0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './mr_widget_status_icon.vue';
import Actions from './action_buttons.vue';
@@ -13,7 +13,30 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ expandDetailsTooltip: {
+ default: '',
+ },
+ collapseDetailsTooltip: {
+ default: '',
+ },
+ },
props: {
+ isCollapsible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapseOnDesktop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
mr: {
type: Object,
required: false,
@@ -35,14 +58,10 @@ export default {
default: () => [],
},
},
- i18n: {
- expandDetailsTooltip: __('Expand merge details'),
- collapseDetailsTooltip: __('Collapse merge details'),
- },
computed: {
wrapperClasses() {
- if (this.status === 'merged') return 'gl-bg-blue-50';
- if (this.status === 'closed') return 'gl-bg-red-50';
+ if (this.status === STATUS_MERGED) return 'gl-bg-blue-50';
+ if (this.status === STATUS_CLOSED) return 'gl-bg-red-50';
return null;
},
hasActionsSlot() {
@@ -54,11 +73,11 @@ export default {
<template>
<div
- class="mr-widget-body media gl-display-flex gl-align-items-center"
+ class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4"
:class="wrapperClasses"
v-on="$listeners"
>
- <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <div v-if="isLoading" class="gl-w-full mr-state-loader">
<slot name="loading">
<div class="gl-display-flex">
<status-icon status="loading" />
@@ -94,21 +113,19 @@ export default {
</div>
</div>
<div
- v-if="mr"
- class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1"
+ v-if="isCollapsible"
+ :class="{ 'gl-md-display-none': !collapseOnDesktop }"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
v-gl-tooltip
- :title="
- mr.mergeDetailsCollapsed
- ? $options.i18n.expandDetailsTooltip
- : $options.i18n.collapseDetailsTooltip
- "
- :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
+ :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
size="small"
class="gl-vertical-align-top"
- @click="() => mr.toggleMergeDetails()"
+ data-testid="widget-toggle"
+ @click="() => $emit('toggle')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index 6d7ec607557..61eec503951 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -43,7 +43,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="failedText" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 837f8b32637..722efe2e6d2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<bold-text :message="$options.message" />
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index bcae1a12344..6299f0fcbb8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -131,16 +131,23 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions">
+ <state-container
+ status="scheduled"
+ :is-loading="loading"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="7" width="150" height="16" rx="4" />
- <rect x="190" y="7" width="144" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!loading">
- <h4 class="gl-mr-3" data-testid="statusText">
+ <h4 class="gl-mr-3 gl-flex-grow-1" data-testid="statusText">
<gl-sprintf :message="statusText" data-testid="statusText">
<template #merge_author>
<mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 448805cf8b9..db5ef6c1a0e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -54,7 +54,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :actions="actions">
+ <state-container
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
<template v-if="mergeError">{{ mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index 670bd36d61e..d4b7d60568b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -15,7 +15,12 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="loading">
+ <state-container
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
{{ s__('mrWidget|Checking if merge request can be merged…') }}
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index 6bcf88713a5..aebba67b39a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -79,7 +79,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="closed" :actions="actions">
+ <state-container
+ status="closed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Closed by')"
:author="mr.metrics.closedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 83d718f5a54..55ae390216d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -72,12 +72,18 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :is-loading="isLoading">
+ <state-container
+ status="failed"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="7" width="150" height="16" rx="4" />
- <rect x="158" y="7" width="84" height="16" rx="4" />
- <rect x="250" y="7" width="84" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
@@ -106,7 +112,7 @@ export default {
v-if="userPermissions.canMerge"
size="small"
variant="confirm"
- category="secondary"
+ category="tertiary"
data-testid="merge-locally-button"
class="js-check-out-modal-trigger gl-align-self-start"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index bfc2c282f4c..742f5d4de14 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -95,12 +95,25 @@ export default {
};
</script>
<template>
- <state-container v-if="isRefreshing" :mr="mr" status="loading">
+ <state-container
+ v-if="isRefreshing"
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
{{ s__('mrWidget|Refreshing now') }}
</span>
</state-container>
- <state-container v-else :mr="mr" status="failed" :actions="actions">
+ <state-container
+ v-else
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
v-if="mr.mergeError"
class="has-error-message gl-font-weight-bold"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 4e2b12799d0..4d906f29cb0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -150,7 +150,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" :actions="actions" status="merged">
+ <state-container
+ :actions="actions"
+ status="merged"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Merged by')"
:author="mr.metrics.mergedBy"
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 c94718ca756..17c51bc4e6e 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
@@ -1,5 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { STATUS_MERGED } from '~/issues/constants';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
@@ -50,7 +51,7 @@ export default {
.poll()
.then((res) => res.data)
.then((data) => {
- if (data.state === 'merged') {
+ if (data.state === STATUS_MERGED) {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index fac8d37712a..415f58ea8e6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -195,11 +195,17 @@ export default {
</script>
<template>
<div>
- <state-container :mr="mr" :status="status" :is-loading="isLoading">
+ <state-container
+ :status="status"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="5" width="302" height="20" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="302" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
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 9e67791afc0..a3c529de27c 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
@@ -16,6 +16,7 @@ import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { createAlert } from '~/alert';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __, s__, n__ } from '~/locale';
@@ -23,6 +24,7 @@ import SmartInterval from '~/smart_interval';
import { helpPagePath } from '~/helpers/help_page_helper';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import {
AUTO_MERGE_STRATEGIES,
WARNING,
@@ -143,6 +145,7 @@ export default {
),
AddedCommitMessage,
RelatedLinks,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -261,7 +264,10 @@ export default {
if (this.isMergingImmediately) {
return __('Merge in progress');
}
- if (this.isAutoMergeAvailable) {
+ if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) {
+ return this.autoMergeTextLegacy;
+ }
+ if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) {
return this.autoMergeText;
}
@@ -271,9 +277,24 @@ export default {
return __('Merge');
},
+ autoMergeLabelsEnabled() {
+ return window.gon?.features?.autoMergeLabelsMrWidget;
+ },
+ showAutoMergeHelperText() {
+ return (
+ !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) &&
+ this.isAutoMergeAvailable
+ );
+ },
hasPipelineMustSucceedConflict() {
return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
+ isNotClosed() {
+ return this.mr.state !== STATUS_CLOSED;
+ },
+ isNeitherClosedNorMerged() {
+ return this.mr.state !== STATUS_CLOSED && this.mr.state !== STATUS_MERGED;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -307,7 +328,7 @@ export default {
);
},
sourceBranchDeletedText() {
- const isPreMerge = this.mr.state !== 'merged';
+ const isPreMerge = this.mr.state !== STATUS_MERGED;
if (isPreMerge) {
return this.mr.shouldRemoveSourceBranch
@@ -325,6 +346,11 @@ export default {
showMergeDetailsHeader() {
return !['readyToMerge'].includes(this.mr.state);
},
+ autoMergeHelpPopoverOptions() {
+ return {
+ title: this.autoMergePopoverSettings.title,
+ };
+ },
},
mounted() {
eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
@@ -495,17 +521,19 @@ export default {
<template>
<div
- :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }"
+ :class="{ 'gl-bg-gray-10': isNeitherClosedNorMerged }"
data-testid="ready_to_merge_state"
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
- <gl-skeleton-loader :width="418" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="0" width="70" height="30" rx="4" />
- <rect x="110" y="7" width="150" height="16" rx="4" />
- <rect x="268" y="7" width="150" height="16" rx="4" />
+ <gl-skeleton-loader :width="418" :height="86">
+ <rect x="0" y="0" width="144" height="20" rx="4" />
+ <rect x="0" y="26" width="100" height="16" rx="4" />
+ <rect x="108" y="26" width="100" height="16" rx="4" />
+ <rect x="0" y="48" width="130" height="16" rx="4" />
+ <rect x="0" y="70" width="80" height="16" rx="4" />
+ <rect x="88" y="70" width="90" height="16" rx="4" />
</gl-skeleton-loader>
</div>
</div>
@@ -517,7 +545,7 @@ export default {
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
<template v-if="shouldShowMergeControls">
<div
- class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-5"
+ class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-2"
>
<gl-form-checkbox
v-if="canRemoveSourceBranch"
@@ -587,9 +615,7 @@ export default {
</li>
</ul>
</div>
- <div
- class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details"
- >
+ <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details">
<template v-if="sourceHasDivergedFromTarget">
<gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText">
<template #link>
@@ -670,7 +696,31 @@ export default {
@cancel="isPipelineFailedModalVisibleNormalMerge = false"
/>
</gl-button-group>
- <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled"
+ class="gl-mx-3"
+ />
+ <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled">
+ <div
+ class="gl-ml-4 gl-text-gray-500 gl-font-sm"
+ data-qa-selector="auto_merge_helper_text"
+ >
+ {{ autoMergeHelperText }}
+ </div>
+ <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions">
+ <gl-sprintf :message="autoMergePopoverSettings.bodyText">
+ <template #link="{ content }">
+ <gl-link
+ :href="autoMergePopoverSettings.helpLink"
+ target="_blank"
+ class="gl-font-sm"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </help-popover>
+ </template>
</template>
<div
v-else
@@ -702,7 +752,7 @@ export default {
/>
</li>
<li
- v-if="mr.state !== 'closed'"
+ v-if="isNotClosed"
class="gl-line-height-normal"
data-testid="source-branch-deleted-text"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 2aa345b420e..9da754d01fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
data-qa-selector="head_mismatch_content"
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 0fd5551979d..af036c01032 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,6 +3,7 @@ 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.');
@@ -15,6 +16,7 @@ export default {
GlButton,
StateContainer,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mr: {
type: Object,
@@ -30,7 +32,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="$options.message" />
</span>
@@ -43,17 +50,17 @@ export default {
category="primary"
@click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Go to first unresolved thread') }}
</gl-button>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
+ 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|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Resolve all with new issue') }}
</gl-button>
</template>
</state-container>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 7163e54985e..7fc4a06cbae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -137,7 +137,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-0! gl-text-body! gl-flex-grow-1">
<bold-text :message="$options.i18n.removeDraftStatus" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
index 6d17ac98d7f..4e8098677cc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
@@ -52,7 +52,7 @@ export default {
}"
class="gl-relative gl-rounded-full gl-mr-3"
>
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
index a754d4e80ea..54eb15c8ac8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -219,6 +219,8 @@ export default {
this.fetchExpandedContent();
}
}
+
+ this.$emit('toggle', { expanded: !this.isCollapsed });
},
async fetchExpandedContent() {
this.isLoadingExpandedContent = true;
@@ -287,7 +289,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="gl-px-5 gl-py-4 gl-align-items-center gl-display-flex">
+ <div class="gl-px-5 gl-pr-4 gl-py-4 gl-align-items-center gl-display-flex">
<status-icon
:level="1"
:name="widgetName"
@@ -346,6 +348,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
+ data-qa-selector="expand_report_button"
@click="toggleCollapsed"
/>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index ff225afbc7b..d2f2d394a1f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import { STATUS_CLOSED } from '~/issues/constants';
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
@@ -82,7 +83,7 @@ export default {
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
- name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
+ name: issue.state === STATUS_CLOSED ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
},
// Badges get rendered next to the text on each row
// badge: issue.state === 'closed' && {
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 183f450854a..a2f088a7a58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,7 +1,3 @@
-// This is a false violation of @gitlab/no-runtime-template-compiler, since it
-// creates a new Vue instance by spreading a _valid_ Vue component definition
-// into the Vue constructor.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
@@ -33,6 +29,10 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
+ // This is a false violation of @gitlab/no-runtime-template-compiler, since it
+ // creates a new Vue instance by spreading a _valid_ Vue component definition
+ // into the Vue constructor.
+ // eslint-disable-next-line @gitlab/no-runtime-template-compiler
const vm = new Vue({
el: '#js-vue-mr-widget',
provide: {
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 ae9111b9504..7e658e77d37 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -16,6 +16,8 @@ export default {
result({ data }) {
const { mergeRequest } = data.project;
+ this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval;
+
this.mr.setApprovals(mergeRequest);
},
error() {
@@ -29,6 +31,7 @@ export default {
return {
alerts: [],
approvals: {},
+ disableCommittersApproval: false,
};
},
methods: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index d964b4bacac..10a54c73273 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,3 +1,4 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
@@ -30,10 +31,25 @@ export default {
pipelineMustSucceedConflictText() {
return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
},
- autoMergeText() {
+ autoMergeTextLegacy() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
},
+ autoMergeText() {
+ return __('Set to auto-merge');
+ },
+ autoMergeHelperText() {
+ return __('Merge when pipeline succeeds');
+ },
+ autoMergePopoverSettings() {
+ return {
+ helpLink: helpPagePath('/user/project/merge_requests/merge_when_pipeline_succeeds.html'),
+ bodyText: __(
+ 'When the pipeline for this merge request succeeds, it will %{linkStart}automatically merge%{linkEnd}.',
+ ),
+ title: __('Merge when pipeline succeeds'),
+ };
+ },
shouldShowMergeImmediatelyDropdown() {
return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
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 bbad2c13220..6e0ee1cb912 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
@@ -10,9 +10,9 @@ import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_wid
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { createAlert } from '~/alert';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
-import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -155,6 +155,10 @@ export default {
},
},
mixins: [mergeRequestQueryVariablesMixin],
+ provide: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
props: {
mrData: {
type: Object,
@@ -226,7 +230,7 @@ export default {
return this.mr.allowCollaboration && this.mr.isOpen;
},
shouldRenderMergedPipeline() {
- return this.mr.state === 'merged' && !isEmpty(this.mr.mergePipeline);
+ return this.mr.state === STATUS_MERGED && !isEmpty(this.mr.mergePipeline);
},
showMergePipelineForkWarning() {
return Boolean(
@@ -264,7 +268,7 @@ export default {
return (this.mr.humanAccess || '').toLowerCase();
},
hasMergeError() {
- return this.mr.mergeError && this.state !== 'closed';
+ return this.mr.mergeError && this.state !== STATUS_CLOSED;
},
hasAlerts() {
return this.hasMergeError || this.showMergePipelineForkWarning;
@@ -416,8 +420,8 @@ export default {
);
},
setFaviconHelper() {
- if (this.mr.ciStatusFaviconPath) {
- return setFaviconOverlay(this.mr.ciStatusFaviconPath);
+ if (this.mr.faviconOverlayPath) {
+ return setFaviconOverlay(this.mr.faviconOverlayPath);
}
return Promise.resolve();
},
@@ -474,7 +478,6 @@ export default {
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
- Project.initRefSwitcher();
}
})
.catch(() =>
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 13009651550..a7758191315 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,5 +1,6 @@
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 { machine } from '~/lib/utils/finite_state_machine';
import {
@@ -121,7 +122,7 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.mergeRequestState = data.state;
- this.isOpen = this.mergeRequestState === 'opened';
+ this.isOpen = this.mergeRequestState === STATUS_OPEN;
this.latestSHA = data.diff_head_sha;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
@@ -139,7 +140,7 @@ export default class MergeRequestStore {
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked =
data.only_allow_merge_if_pipeline_succeeds && pipelineStatus?.group === 'manual';
- this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+ this.faviconOverlayPath = data.favicon_overlay_path;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
this.accessibilityReportPath = data.accessibility_report_path;
@@ -236,11 +237,11 @@ export default class MergeRequestStore {
this.state = getStateKey.call(this);
} else {
switch (this.mergeRequestState) {
- case 'merged':
- this.state = 'merged';
+ case STATUS_MERGED:
+ this.state = STATUS_MERGED;
break;
- case 'closed':
- this.state = 'closed';
+ case STATUS_CLOSED:
+ this.state = STATUS_CLOSED;
break;
default:
this.state = null;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
deleted file mode 100644
index 9d5006564ef..00000000000
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import * as Sentry from '@sentry/browser';
-import Vue from 'vue';
-import Vuex from 'vuex';
-
-Vue.use(Vuex);
-
-export default {
- props: {
- dashboardUrl: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- metricEmbedComponent: null,
- namespace: 'alertMetrics',
- };
- },
- mounted() {
- if (this.dashboardUrl) {
- Promise.all([
- import('~/monitoring/components/embeds/metric_embed.vue'),
- import('~/monitoring/stores'),
- ])
- .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => {
- this.$store = new Vuex.Store({
- modules: {
- [this.namespace]: monitoringDashboard,
- },
- });
- this.metricEmbedComponent = MetricEmbed;
- })
- .catch((e) => Sentry.captureException(e));
- }
- },
-};
-</script>
-
-<template>
- <div class="gl-py-3">
- <div v-if="dashboardUrl" ref="metricsChart">
- <component
- :is="metricEmbedComponent"
- v-if="metricEmbedComponent"
- :dashboard-url="dashboardUrl"
- :namespace="namespace"
- />
- </div>
- <div v-else ref="emptyState">
- {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }}
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index f2c27cf611e..0577279cdd0 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -53,11 +53,11 @@ export default {
},
methods: {
updateToDoCount(add) {
- const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
+ const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10) || 0;
const count = add ? oldCount + 1 : oldCount - 1;
const headerTodoEvent = new CustomEvent('todo:toggle', {
detail: {
- count,
+ count: Math.max(count, 0),
},
});
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index d106f545c61..4ee8d19770d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -9,7 +9,8 @@ export const SEVERITY_LEVELS = {
UNKNOWN: s__('severity|Unknown'),
};
-/* eslint-disable @gitlab/require-i18n-strings */
+const category = 'Alert Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
export const PAGE_CONFIG = {
OPERATIONS: {
TITLE: 'OPERATIONS',
@@ -20,14 +21,14 @@ export const PAGE_CONFIG = {
},
// Tracks snowplow event when user views alert details
TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'view_alert_details',
},
// Tracks snowplow event when alert status is updated
TRACK_ALERT_STATUS_UPDATE_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'update_alert_status',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index cb38b3e13bb..8f1f7ba0ad8 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -35,11 +35,6 @@ export default {
required: false,
default: NO_USER_ID,
},
- addButtonClass: {
- type: String,
- required: false,
- default: '',
- },
defaultAwards: {
type: Array,
required: false,
@@ -50,6 +45,11 @@ export default {
required: false,
default: 'selected',
},
+ boundary: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -201,6 +201,8 @@ export default {
v-gl-tooltip.viewport
:title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
+ :right="false"
+ :boundary="boundary"
data-testid="emoji-picker"
@click="handleAward"
@shown="setIsMenuOpen(true)"
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index c89e843b660..b4751d51fcb 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -1,4 +1,5 @@
<script>
+import { v4 as uuidv4 } from 'uuid';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { CHART_CONTAINER_HEIGHT } from './constants';
@@ -17,6 +18,15 @@ export default {
required: true,
},
},
+ data: () => ({
+ chartKey: uuidv4(),
+ }),
+ watch: {
+ chartData() {
+ // Re-render area chart when the data changes
+ this.chartKey = uuidv4();
+ },
+ },
chartContainerHeight: CHART_CONTAINER_HEIGHT,
};
</script>
@@ -27,6 +37,7 @@ export default {
</p>
<gl-area-chart
v-bind="$attrs"
+ :key="chartKey"
responsive
width="auto"
:height="$options.chartContainerHeight"
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 47b96934420..a30b18348ec 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -39,11 +39,10 @@ export default {
</script>
<template>
<div>
- <segmented-control-button-group
- v-model="selectedChart"
- :options="chartRanges"
- class="gl-mb-4"
- />
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <segmented-control-button-group v-model="selectedChart" :options="chartRanges" />
+ <slot name="extend-button-group"></slot>
+ </div>
<ci-cd-analytics-area-chart
v-if="chart"
v-bind="$attrs"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
new file mode 100644
index 00000000000..97143d90c3f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
@@ -0,0 +1,11 @@
+import { RENAMED_DIFF_TRANSITIONS } from '~/diffs/constants';
+
+export const transition = (currentState, transitionEvent) => {
+ const key = `${currentState}:${transitionEvent}`;
+
+ if (RENAMED_DIFF_TRANSITIONS[key]) {
+ return RENAMED_DIFF_TRANSITIONS[key];
+ }
+
+ return currentState;
+};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index b786f7752df..f7b817423de 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -10,15 +10,14 @@ import {
STATE_IDLING,
STATE_LOADING,
STATE_ERRORED,
- RENAMED_DIFF_TRANSITIONS,
} from '~/diffs/constants';
import { truncateSha } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
+import { transition } from '../utils';
export default {
STATE_LOADING,
STATE_ERRORED,
- TRANSITIONS: RENAMED_DIFF_TRANSITIONS,
uiText: {
showLink: __('Show file contents'),
commitLink: __('View file @ %{commitSha}'),
@@ -52,27 +51,23 @@ export default {
},
methods: {
...mapActions('diffs', ['switchToFullDiffFromRenamedFile']),
- transition(transitionEvent) {
- const key = `${this.state}:${transitionEvent}`;
-
- if (this.$options.TRANSITIONS[key]) {
- this.state = this.$options.TRANSITIONS[key];
- }
- },
is(state) {
return this.state === state;
},
switchToFull() {
- this.transition(TRANSITION_LOAD_START);
+ this.transitionState(TRANSITION_LOAD_START);
this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile })
.then(() => {
- this.transition(TRANSITION_LOAD_SUCCEED);
+ this.transitionState(TRANSITION_LOAD_SUCCEED);
})
.catch(() => {
- this.transition(TRANSITION_LOAD_ERROR);
+ this.transitionState(TRANSITION_LOAD_ERROR);
});
},
+ transitionState(transitionEvent) {
+ this.state = transition(this.state, transitionEvent);
+ },
clickLink(event) {
if (this.canLoadFullDiff) {
event.preventDefault();
@@ -81,7 +76,7 @@ export default {
}
},
dismissError() {
- this.transition(TRANSITION_ACKNOWLEDGE_ERROR);
+ this.transitionState(TRANSITION_ACKNOWLEDGE_ERROR);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
index 1da84df022f..b920af593df 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -27,6 +27,12 @@ export default {
type: Number,
required: true,
},
+ /* enable possibility to cycle around */
+ enableCycle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
max() {
@@ -64,15 +70,34 @@ export default {
return;
}
- const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
+ let nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
- // Return if the index didn't change
if (nextIndex === this.index) {
- return;
+ // Return if the index didn't change and cycle is not enabled
+ if (!this.enableCycle) {
+ return;
+ }
+ // Update nextIndex if the cycle is enabled
+ nextIndex = this.cycle(nextIndex, val);
}
this.$emit('change', nextIndex);
},
+ cycle(nextIndex, val) {
+ if (val === 1 && nextIndex === this.max) {
+ // if we are moving down +1 and we reached bottom (max)
+ // return top most index (min)
+ return this.min;
+ }
+
+ if (val === -1 && nextIndex === this.min) {
+ // if we are moving up -1 and we reached top (min)
+ // return bottom most index (max)
+ return this.max;
+ }
+
+ return nextIndex;
+ },
},
render() {
return this.$scopedSlots.default?.();
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index 45c50dce8ce..9b45e969c90 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -13,6 +13,11 @@ export default {
GlCollapsibleListbox,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -176,6 +181,7 @@ export default {
<gl-collapsible-listbox
ref="listbox"
v-model="selected"
+ :block="block"
:header-text="headerText"
:reset-button-label="resetButtonLabel"
:toggle-text="toggleText"
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
index 1afbeda74c4..12db70d8e9c 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
@@ -20,6 +20,8 @@ export const initProjectSelects = () => {
orderBy,
selected: initialSelection,
} = el.dataset;
+ const block = parseBoolean(el.dataset.block);
+ const withShared = parseBoolean(el.dataset.withShared);
const includeSubgroups = parseBoolean(el.dataset.includeSubgroups);
const membership = parseBoolean(el.dataset.membership);
const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel);
@@ -37,6 +39,8 @@ export const initProjectSelects = () => {
groupId,
userId,
orderBy,
+ block,
+ withShared,
includeSubgroups,
membership,
initialSelection,
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
index 393991d746e..7af3819f2a5 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
@@ -20,6 +20,11 @@ export default {
SafeHtml,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -47,6 +52,11 @@ export default {
required: false,
default: null,
},
+ withShared: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
includeSubgroups: {
type: Boolean,
required: false,
@@ -86,7 +96,7 @@ export default {
if (this.groupId) {
return Api.groupProjects(this.groupId, searchString, {
...commonParams,
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
simple: true,
});
@@ -99,7 +109,7 @@ export default {
this.userId,
searchString,
{
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
},
(res) => ({ data: res }),
@@ -154,6 +164,7 @@ export default {
:default-toggle-text="$options.i18n.searchForProject"
:fetch-items="fetchProjects"
:fetch-initial-selection-text="fetchProjectName"
+ :block="block"
clearable
>
<template v-if="hasHtmlLabel" #label>
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 b0fa3e4c27e..b5783265ffa 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,7 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants';
+import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants';
import {
getRecentlyUsedSuggestions,
setTokenValueToRecentlyUsed,
@@ -100,7 +100,7 @@ export default {
return this.getActiveTokenValue(this.suggestions, this.value.data);
},
availableDefaultSuggestions() {
- if (this.value.operator === OPERATOR_NOT) {
+ if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) {
return this.defaultSuggestions.filter(
(suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value),
);
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 2f10e068542..dea279890b1 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -137,6 +137,7 @@ export default {
v-if="showCopyButton"
:text="value"
:title="copyButtonTitle"
+ data-qa-selector="clipboard_button"
@click="handleCopyButtonClick"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
index 989b14f8711..8b3a54a536e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { updateText } from '~/lib/utils/text_markdown';
import savedRepliesQuery from './saved_replies.query.graphql';
export default {
@@ -9,7 +10,7 @@ export default {
query: savedRepliesQuery,
update: (r) => r.currentUser?.savedReplies?.nodes,
skip() {
- return !this.shouldFetchSavedReplies;
+ return !this.shouldFetchCommentTemplates;
},
},
},
@@ -22,34 +23,52 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
- newSavedRepliesPath: {
+ newCommentTemplatePath: {
type: String,
required: true,
},
},
data() {
return {
- shouldFetchSavedReplies: false,
+ shouldFetchCommentTemplates: false,
savedReplies: [],
- savedRepliesSearch: '',
+ commentTemplateSearch: '',
loadingSavedReplies: false,
};
},
computed: {
filteredSavedReplies() {
- const savedReplies = this.savedRepliesSearch
- ? fuzzaldrinPlus.filter(this.savedReplies, this.savedRepliesSearch, { key: ['name'] })
+ const savedReplies = this.commentTemplateSearch
+ ? fuzzaldrinPlus.filter(this.savedReplies, this.commentTemplateSearch, { key: ['name'] })
: this.savedReplies;
return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content }));
},
},
methods: {
- fetchSavedReplies() {
- this.shouldFetchSavedReplies = true;
+ fetchCommentTemplates() {
+ this.shouldFetchCommentTemplates = true;
},
- setSavedRepliesSearch(search) {
- this.savedRepliesSearch = search;
+ setCommentTemplateSearch(search) {
+ this.commentTemplateSearch = search;
+ },
+ onSelect(id) {
+ const savedReply = this.savedReplies.find((r) => r.id === id);
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+
+ if (savedReply && textArea) {
+ updateText({
+ textArea,
+ tag: savedReply.content,
+ cursorOffset: 0,
+ wrap: false,
+ });
+
+ // Wait for text to be added into textarea
+ requestAnimationFrame(() => {
+ textArea.focus();
+ });
+ }
},
},
};
@@ -57,36 +76,32 @@ export default {
<template>
<gl-collapsible-listbox
- :header-text="__('Insert saved reply')"
+ :header-text="__('Insert comment template')"
:items="filteredSavedReplies"
placement="right"
searchable
- class="saved-replies-dropdown"
+ class="comment-template-dropdown"
:searching="$apollo.queries.savedReplies.loading"
- @shown="fetchSavedReplies"
- @search="setSavedRepliesSearch"
+ @shown="fetchCommentTemplates"
+ @search="setCommentTemplateSearch"
+ @select="onSelect"
>
<template #toggle>
<gl-button
v-gl-tooltip
- :title="__('Insert saved reply')"
- :aria-label="__('Insert saved reply')"
+ :title="__('Insert comment template')"
+ :aria-label="__('Insert comment template')"
category="tertiary"
class="gl-px-3!"
- data-testid="saved-replies-dropdown-toggle"
+ data-testid="comment-template-dropdown-toggle"
+ @keydown.prevent
>
<gl-icon name="symlink" class="gl-mr-0!" />
<gl-icon name="chevron-down" />
</gl-button>
</template>
<template #list-item="{ item }">
- <div
- class="gl-display-flex js-saved-reply-content"
- :data-md-tag="item.content"
- data-md-cursor-offset="0"
- data-md-prepend="true"
- data-testid="saved-reply-dropdown-item"
- >
+ <div class="gl-display-flex js-comment-template-content">
<div class="gl-text-truncate">
<strong>{{ item.text }}</strong
><span class="gl-ml-2">{{ item.content }}</span>
@@ -98,11 +113,11 @@ export default {
class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3"
>
<gl-button
- :href="newSavedRepliesPath"
+ :href="newCommentTemplatePath"
category="tertiary"
block
class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
- >{{ __('Add a new saved reply') }}</gl-button
+ >{{ __('Add a new comment template') }}</gl-button
>
</div>
</template>
@@ -110,11 +125,11 @@ export default {
</template>
<style>
-.saved-replies-dropdown .gl-new-dropdown-panel {
+.comment-template-dropdown .gl-new-dropdown-panel {
width: 350px;
}
-.saved-replies-dropdown .gl-new-dropdown-item-check-icon {
+.comment-template-dropdown .gl-new-dropdown-item-check-icon {
display: none;
}
</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
index 9ebf782a1d9..7803d6f53e0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
@@ -23,7 +23,7 @@ export default {
return this.value === 'markdown';
},
text() {
- return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text');
+ return this.markdownEditorSelected ? __('Editing markdown') : __('Editing rich text');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 9623c51d51c..cc153747765 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -416,7 +416,7 @@ export default {
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
- class="referenced-commands"
+ class="referenced-commands gl-mx-n5"
data-testid="referenced-commands"
></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index eeeb0fce55d..3486f231b39 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -17,7 +17,7 @@ import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
import DrawioToolbarButton from './drawio_toolbar_button.vue';
-import SavedRepliesDropdown from './saved_replies_dropdown.vue';
+import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
export default {
components: {
@@ -27,16 +27,19 @@ export default {
GlTabs,
GlTab,
DrawioToolbarButton,
- SavedRepliesDropdown,
+ CommentTemplatesDropdown,
+ AiActionsDropdown: () =>
+ import('ee_component/vue_shared/components/markdown/ai_actions_dropdown.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
inject: {
- newSavedRepliesPath: {
+ newCommentTemplatePath: {
default: null,
},
+ resourceGlobalId: { default: null },
},
props: {
previewMarkdown: {
@@ -118,6 +121,9 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
+ showAiActions() {
+ return this.resourceGlobalId && this.glFeatures.summarizeComments;
+ },
},
watch: {
showSuggestPopover() {
@@ -269,6 +275,7 @@ export default {
</gl-button>
</gl-popover>
</template>
+ <ai-actions-dropdown v-if="showAiActions" :resource-global-id="resourceGlobalId" />
<toolbar-button
tag="**"
:button-title="
@@ -400,9 +407,9 @@ export default {
:uploads-path="uploadsPath"
:markdown-preview-path="markdownPreviewPath"
/>
- <saved-replies-dropdown
- v-if="newSavedRepliesPath && glFeatures.savedReplies"
- :new-saved-replies-path="newSavedRepliesPath"
+ <comment-templates-dropdown
+ v-if="newCommentTemplatePath && glFeatures.savedReplies"
+ :new-comment-template-path="newCommentTemplatePath"
/>
<toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
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 93583907a11..52d8aab30d5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -20,6 +20,11 @@ export default {
type: String,
required: true,
},
+ setFacade: {
+ type: Function,
+ required: false,
+ default: null,
+ },
renderMarkdownPath: {
type: String,
required: true,
@@ -44,6 +49,16 @@ export default {
required: false,
default: false,
},
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
supportsQuickActions: {
type: Boolean,
required: false,
@@ -54,6 +69,11 @@ export default {
required: false,
default: null,
},
+ markdownDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
quickActionsDocsPath: {
type: String,
required: false,
@@ -97,9 +117,25 @@ export default {
mounted() {
this.autofocusTextarea();
+ this.$emit('input', this.markdown);
this.saveDraft();
+
+ this.setFacade?.({
+ getValue: () => this.getValue(),
+ setValue: (val) => this.setValue(val),
+ });
},
methods: {
+ getValue() {
+ return this.markdown;
+ },
+ setValue(value) {
+ this.markdown = value;
+ this.$emit('input', value);
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
updateMarkdownFromContentEditor({ markdown }) {
this.markdown = markdown;
this.$emit('input', markdown);
@@ -121,6 +157,11 @@ export default {
this.notifyEditingModeChange(editingMode);
},
onEditingModeRestored(editingMode) {
+ if (editingMode === EDITING_MODE_CONTENT_EDITOR && !this.enableContentEditor) {
+ this.editingMode = EDITING_MODE_MARKDOWN_FIELD;
+ return;
+ }
+
this.editingMode = editingMode;
this.$emit(editingMode);
this.notifyEditingModeChange(editingMode);
@@ -161,7 +202,8 @@ export default {
<div>
<local-storage-sync
v-model="editingMode"
- storage-key="gl-wiki-content-editor-enabled"
+ as-string
+ storage-key="gl-markdown-editor-mode"
@input="onEditingModeRestored"
/>
<markdown-field
@@ -173,11 +215,14 @@ export default {
can-attach-file
:textarea-value="markdown"
:uploads-path="uploadsPath"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:show-content-editor-switcher="enableContentEditor"
:drawio-enabled="drawioEnabled"
- class="bordered-box"
@enableContentEditor="onEditingModeChange('contentEditor')"
+ @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<textarea
@@ -205,6 +250,8 @@ export default {
:autofocus="contentEditorAutofocused"
:placeholder="formFieldProps.placeholder"
:drawio-enabled="drawioEnabled"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
:editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
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
new file mode 100644
index 00000000000..0f2a46f78f7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import MarkdownEditor from './markdown_editor.vue';
+
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
+function organizeQuery(obj, isFallbackKey = false) {
+ if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
+ return obj;
+ }
+
+ if (isFallbackKey) {
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ };
+ }
+
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH],
+ };
+}
+
+function format(searchTerm, isFallbackKey = false) {
+ const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true });
+ const organizeQueryObject = organizeQuery(queryObject, isFallbackKey);
+ const formattedQuery = objectToQuery(organizeQueryObject);
+
+ return formattedQuery;
+}
+
+function getSearchTerm(newIssuePath) {
+ const { search, pathname } = document.location;
+ return newIssuePath === pathname ? '' : format(search);
+}
+
+export function mountMarkdownEditor() {
+ const el = document.querySelector('.js-markdown-editor');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldPlaceholder,
+ formFieldClasses,
+ qaSelector,
+ newIssuePath,
+ } = el.dataset;
+
+ const hiddenInput = el.querySelector('input[type="hidden"]');
+ const formFieldName = hiddenInput.getAttribute('name');
+ const formFieldId = hiddenInput.getAttribute('id');
+ const formFieldValue = hiddenInput.value;
+
+ const searchTerm = getSearchTerm(newIssuePath);
+ const facade = {
+ setValue() {},
+ getValue() {},
+ focus() {},
+ };
+
+ const setFacade = (props) => Object.assign(facade, props);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(h) {
+ return h(MarkdownEditor, {
+ props: {
+ setFacade,
+ enableContentEditor: Boolean(gon.features?.contentEditorOnIssues),
+ value: formFieldValue,
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldProps: {
+ placeholder: formFieldPlaceholder,
+ id: formFieldId,
+ name: formFieldName,
+ class: formFieldClasses,
+ 'data-qa-selector': qaSelector,
+ },
+ autosaveKey: `autosave/${document.location.pathname}/${searchTerm}/description`,
+ enableAutocomplete: true,
+ autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
+ supportsQuickActions: true,
+ autofocus: true,
+ },
+ });
+ },
+ });
+
+ return facade;
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 49eb11f8081..6d1cadf15be 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -176,6 +176,12 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
+ <div
+ v-show="isRendered"
+ ref="container"
+ v-safe-html="noteHtml"
+ data-testid="suggestions-container"
+ class="md suggestions"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index e091fe74717..fac32bfdb24 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -13,7 +13,9 @@ export default {
<template>
<timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
- <div class="timeline-icon"></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ ></div>
<div class="timeline-content">
<div class="note-header"></div>
<div class="note-body gl-mt-4"><gl-skeleton-loader /></div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 1cbbdf0deb0..06ca90fa8c6 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -25,11 +25,18 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { spriteIcon } from '~/lib/utils/common_utils';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+const MR_ICON_COLORS = {
+ check: 'gl-bg-green-100 gl-text-green-700',
+ 'merge-request-close': 'gl-bg-red-100 gl-text-red-700',
+ merge: 'gl-bg-blue-100 gl-text-blue-700',
+};
+const ICON_COLORS = {
+ 'issue-close': 'gl-bg-blue-100 gl-text-blue-700',
+};
export default {
i18n: {
@@ -63,7 +70,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'descriptionVersions']),
+ ...mapGetters(['targetNoteHash', 'descriptionVersions', 'getNoteableData']),
...mapState(['isLoadingDescriptionVersion']),
noteAnchorId() {
return `note_${this.note.id}`;
@@ -71,9 +78,6 @@ export default {
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
- iconHtml() {
- return spriteIcon(this.note.system_note_icon_name);
- },
toggleIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
@@ -87,6 +91,19 @@ export default {
descriptionVersion() {
return this.descriptionVersions[this.note.description_version_id];
},
+ isMergeRequest() {
+ return this.getNoteableData.noteableType === 'MergeRequest';
+ },
+ hasIconColors() {
+ if (!this.isMergeRequest) return true;
+
+ return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name];
+ },
+ iconBgClass() {
+ const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS;
+
+ return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600';
+ },
},
mounted() {
renderGFM(this.$refs['gfm-content']);
@@ -108,9 +125,6 @@ export default {
}
},
},
- safeHtmlConfig: {
- ADD_TAGS: ['use'], // to support icon SVGs
- },
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@@ -121,7 +135,24 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div>
+ <div
+ :class="[
+ iconBgClass,
+ {
+ 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors,
+ 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest,
+ 'mr-system-note-icon': isMergeRequest,
+ },
+ ]"
+ class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon"
+ >
+ <gl-icon
+ v-if="note.system_note_icon_name && hasIconColors"
+ :name="note.system_note_icon_name"
+ :size="isMergeRequest ? 12 : 16"
+ data-testid="timeline-icon"
+ />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
index 35f9ac14681..9d5f494579b 100644
--- a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
@@ -1,3 +1,7 @@
<template>
- <div class="timeline-icon"><slot></slot></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <slot></slot>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
new file mode 100644
index 00000000000..1ace1c52a68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -0,0 +1,37 @@
+<script>
+import ProjectsListItem from './projects_list_item.vue';
+
+export default {
+ components: { ProjectsListItem },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * }[];
+ */
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <projects-list-item v-for="project in projects" :key="project.id" :project="project" />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
new file mode 100644
index 00000000000..f77fd029e93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -0,0 +1,152 @@
+<script>
+import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+
+import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_ENABLED } from '~/featurable/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+
+export default {
+ i18n: {
+ stars: __('Stars'),
+ forks: __('Forks'),
+ issues: __('Issues'),
+ archived: __('Archived'),
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlLink,
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ */
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.project.visibility];
+ },
+ visibilityTooltip() {
+ return PROJECT_VISIBILITY_TYPE[this.project.visibility];
+ },
+ accessLevel() {
+ return this.project.permissions?.projectAccess?.accessLevel;
+ },
+ accessLevelLabel() {
+ return ACCESS_LEVEL_LABELS[this.accessLevel];
+ },
+ shouldShowAccessLevel() {
+ return this.accessLevel !== undefined;
+ },
+ starsHref() {
+ return `${this.project.webUrl}/-/starrers`;
+ },
+ forksHref() {
+ return `${this.project.webUrl}/-/forks`;
+ },
+ issuesHref() {
+ return `${this.project.webUrl}/-/issues`;
+ },
+ isForkingEnabled() {
+ return (
+ this.project.forkingAccessLevel === FEATURABLE_ENABLED &&
+ this.project.forksCount !== undefined
+ );
+ },
+ isIssuesEnabled() {
+ return this.project.issuesAccessLevel === FEATURABLE_ENABLED;
+ },
+ },
+ methods: {
+ numberToMetricPrefix,
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+ <gl-avatar-labeled
+ class="gl-flex-grow-1"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="48"
+ >
+ <template #meta>
+ <gl-icon
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary gl-ml-3"
+ />
+ <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </template>
+ </gl-avatar-labeled>
+ <div
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
+ <gl-link
+ v-gl-tooltip="$options.i18n.stars"
+ :href="starsHref"
+ :aria-label="$options.i18n.stars"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="star-o" />
+ <span>{{ numberToMetricPrefix(project.starCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isForkingEnabled"
+ v-gl-tooltip="$options.i18n.forks"
+ :href="forksHref"
+ :aria-label="$options.i18n.forks"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="fork" />
+ <span>{{ numberToMetricPrefix(project.forksCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isIssuesEnabled"
+ v-gl-tooltip="$options.i18n.issues"
+ :href="issuesHref"
+ :aria-label="$options.i18n.issues"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="issues" />
+ <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
+ </gl-link>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
index 384b084ce09..d7d62df78f5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
@@ -19,7 +19,9 @@ export default {
<template>
<timeline-entry-item class="system-note note-wrapper">
- <div class="timeline-icon">
+ <div
+ class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left"
+ >
<gl-icon :name="icon" />
</div>
<div class="timeline-content">
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 092e8ba6c15..d77061d4b31 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,6 +1,5 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
@@ -21,7 +20,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
isHighlighted: {
type: Boolean,
@@ -69,7 +67,6 @@ export default {
return this.content.split('\n');
},
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -106,7 +103,6 @@ export default {
class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
class="gl-user-select-none gl-shadow-none! file-line-blame"
:href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
></a>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
index ce6741f33b1..f121e84e1de 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -1,13 +1,11 @@
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
number: {
type: Number,
@@ -28,7 +26,6 @@ export default {
},
computed: {
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -41,8 +38,7 @@ export default {
class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
- class="gl-user-select-none gl-shadow-none! file-line-blame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame gl-mx-n2 gl-flex-grow-1"
:href="`${blamePath}${pageSearchString}#L${number}`"
></a>
<a
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index dd9d2ce66cd..e09f193310b 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlBadge,
GlPopover,
GlLink,
GlSkeletonLoader,
@@ -35,6 +36,7 @@ export default {
I18N_USER_LEARN,
USER_POPOVER_DELAY,
components: {
+ GlBadge,
GlIcon,
GlLink,
GlPopover,
@@ -226,9 +228,9 @@ export default {
data-testid="user-popover-pronouns"
>({{ user.pronouns }})</span
>
- <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
- >({{ $options.I18N_USER_BUSY }})</span
- >
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-1">
+ {{ $options.I18N_USER_BUSY }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</div>
@@ -269,7 +271,7 @@ export default {
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="$options.I18N_USER_LEARN">
<template #name>{{ user.name }}</template>
diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
index 4ef9bc07b1c..9665e188469 100644
--- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -5,7 +5,9 @@ export default {
// We can't use this.vuexModule due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- vuexModule: this.$options.propsData.vuexModule,
+
+ // @vue-compat does not care to normalize propsData fields
+ vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'],
};
},
props: {
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
index 388e7c92f03..4211b9578a2 100644
--- a/app/assets/javascripts/vue_shared/global_search/constants.js
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -27,6 +27,10 @@ export const KBD_HELP = sprintf(
{ kbdOpen: '<kbd>', kbdClose: '</kbd>' },
false,
);
+export const MIN_SEARCH_TERM = s__(
+ 'GlobalSearch|The search term must be at least 3 characters long.',
+);
+
export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}');
export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index 5b303b9a314..a68c577bff6 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
@@ -2,6 +2,7 @@
import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
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';
@@ -93,13 +94,13 @@ export default {
return getTimeago().format(this.issuable.createdAt);
},
timestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return this.issuable.closedAt;
}
return this.issuable.updatedAt;
},
formattedTimestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return sprintf(__('closed %{timeago}'), {
timeago: getTimeago().format(this.issuable.closedAt),
});
@@ -226,7 +227,7 @@ export default {
</gl-link>
<span
v-if="taskStatus"
- class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm"
data-testid="task-status"
>
{{ taskStatus }}
@@ -265,7 +266,7 @@ export default {
:data-avatar-url="author.avatarUrl"
:href="author.webUrl"
data-testid="issuable-author"
- class="author-link js-user-link"
+ class="author-link js-user-link gl-font-sm"
>
<span class="author">{{ author.name }}</span>
</gl-link>
@@ -285,8 +286,7 @@ export default {
</span>
<slot name="timeframe"></slot>
</span>
- &nbsp;
- <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <p v-if="labels.length" role="group" :aria-label="__('Labels')" class="gl-mt-1 gl-mb-0">
<gl-label
v-for="(label, index) in labels"
:key="index"
@@ -295,10 +295,10 @@ export default {
:description="label.description"
:scoped="scopedLabel(label)"
:target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
+ class="gl-mr-2"
size="sm"
/>
- </span>
+ </p>
</div>
</div>
<div class="issuable-meta">
@@ -312,7 +312,7 @@ export default {
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
+ class="gl-align-items-center gl-display-flex"
/>
</li>
<slot name="statistics"></slot>
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 5b6c5bf6e03..3ac6aaf8b86 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
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
@@ -24,6 +24,7 @@ export default {
},
components: {
GlAlert,
+ GlBadge,
GlKeysetPagination,
GlSkeletonLoader,
IssuableTabs,
@@ -371,7 +372,9 @@ export default {
<slot name="timeframe" :issuable="issuable"></slot>
</template>
<template #status>
- <slot name="status" :issuable="issuable"></slot>
+ <gl-badge size="sm" variant="info">
+ <slot name="status" :issuable="issuable"></slot>
+ </gl-badge>
</template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index a8d5f72373c..45fde45f516 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -139,7 +139,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description js-detail-page-description content-block gl-pt-2">
+ <div class="detail-page-description js-detail-page-description content-block gl-pt-4">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index 26309a25f07..08e52442311 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -20,7 +20,9 @@ export default {
};
</script>
<template>
- <div class="container gl-display-flex gl-flex-direction-column">
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-mt-4 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid"
+ >
<h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
{{ title }}
</h2>
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 2533b3b5489..31fd9e0a0ec 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
@@ -130,17 +130,19 @@ export default {
<template>
<credit-card-verification v-if="shouldVerify" @verified="onVerified" />
- <div v-else-if="!activePanelName">
- <gl-breadcrumb :items="breadcrumbs" />
+ <div v-else-if="!activePanelName" class="gl-mt-4">
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
<welcome-page :panels="panels" :title="title">
<template #footer>
<slot name="welcome-footer"> </slot>
</template>
</welcome-page>
</div>
- <div v-else>
- <gl-breadcrumb :items="breadcrumbs" />
- <div class="gl-display-flex gl-py-5 gl-align-items-center">
+ <div v-else class="gl-pt-4">
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
+ <div
+ class="gl-display-flex gl-align-items-center gl-mt-4 gl-py-5 gl-border-t-1 gl-border-t-gray-100 gl-border-t-solid"
+ >
<div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
<div class="col">
<h4>{{ activePanel.title }}</h4>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 3afd1f9410b..c2ff2eec9fa 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import { parseErrorMessage } from '~/lib/utils/error_message';
import { redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../provider';
@@ -9,6 +10,16 @@ function mutationSettingsForFeatureType(type) {
return featureToMutationMap[type];
}
+export const i18n = {
+ buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
+ noSuccessPathError: s__(
+ 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
+ ),
+ genericErrorText: s__(
+ `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
+ ),
+};
+
export default {
apolloProvider,
components: {
@@ -55,15 +66,20 @@ export default {
throw new Error(errors[0]);
}
+ // Sending window.gon.uf_error_prefix prefixed messages should happen only in
+ // the backend. Hence the code below is an anti-pattern.
+ // The issue to refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/397714
if (!successPath) {
throw new Error(
- sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
+ `${window.gon.uf_error_prefix} ${sprintf(this.$options.i18n.noSuccessPathError, {
+ featureName: this.feature.name,
+ })}`,
);
}
redirectTo(successPath);
} catch (e) {
- this.$emit('error', e.message);
+ this.$emit('error', parseErrorMessage(e, this.$options.i18n.genericErrorText));
this.isLoading = false;
}
},
@@ -84,12 +100,7 @@ export default {
Boolean(mutationSettingsForFeatureType(type))
);
},
- i18n: {
- buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
- noSuccessPathError: s__(
- 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
- ),
- },
+ i18n,
};
</script>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index fafbd02634f..8b523645973 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -20,6 +20,7 @@ export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_SAST_IAC = 'sast_iac';
export const REPORT_TYPE_DAST = 'dast';
export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
+export const REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION = 'breach_and_attack_simulation';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
@@ -28,6 +29,7 @@ export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
+export const REPORT_TYPE_MANUALLY_ADDED = 'generic';
/**
* SecurityReportTypeEnum values for use with GraphQL.
diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue
index 78e5dff6f59..90a8a7aa3e6 100644
--- a/app/assets/javascripts/webhooks/components/test_dropdown.vue
+++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue
@@ -19,45 +19,16 @@ export default {
},
},
computed: {
- itemsWithAction() {
- return this.items.map((item) => ({
- text: item.text,
- action: () => this.testHook(item.href),
+ webhookTriggers() {
+ return this.items.map(({ text, href }) => ({
+ text,
+ href,
+ extraAttrs: {
+ 'data-method': 'post',
+ },
}));
},
},
- methods: {
- testHook(href) {
- // HACK: Trigger @rails/ujs's data-method handling.
- //
- // The more obvious approaches of (1) declaratively rendering the
- // links using GlDisclosureDropdown's list-item slot and (2) using
- // item.extraAttrs to set the data-method attributes on the links
- // do not work for reasons laid out in
- // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134.
- //
- // Sending the POST with axios also doesn't work, since the
- // endpoints return 302 redirects. Since axios uses XMLHTTPRequest,
- // it transparently follows redirects, meaning the Location header
- // of the first response cannot be inspected/acted upon by JS. We
- // could manually trigger a reload afterwards, but that would mean
- // a duplicate fetch of the current page: one by the XHR, and one
- // by the explicit reload. It would also mean losing the flash
- // alert set by the backend, making the feature useless for the
- // user.
- //
- // The ideal fix here would be to refactor the test endpoint to
- // return a JSON response, removing the need for a redirect/page
- // reload to show the result.
- const a = document.createElement('a');
- a.setAttribute('hidden', '');
- a.href = href;
- a.dataset.method = 'post';
- document.body.appendChild(a);
- a.click();
- a.remove();
- },
- },
i18n: {
test: __('Test'),
},
@@ -65,5 +36,5 @@ export default {
</script>
<template>
- <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" />
+ <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="webhookTriggers" :size="size" />
</template>
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 ab4691a4a4e..f8dfa1c7f01 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -127,7 +127,11 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
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 1762344ea9e..6c27d5a87f0 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
@@ -1,9 +1,9 @@
<script>
-import { GlAvatar, GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { clearDraft } from '~/lib/utils/autosave';
import Tracking from '~/tracking';
import { ASC } from '~/notes/constants';
+import { __ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getWorkItemQuery } from '../../utils';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
@@ -17,8 +17,6 @@ export default {
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
- GlAvatar,
- GlButton,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
@@ -66,11 +64,25 @@ export default {
required: false,
default: ASC,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
workItem: {},
- isEditing: false,
+ isEditing: this.isNewDiscussion,
isSubmitting: false,
isSubmittingWithKeydown: false,
};
@@ -109,28 +121,9 @@ export default {
property: `type_${this.workItemType}`,
};
},
- markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
- },
- isLockedOutOrSignedOut() {
- return !this.signedIn || !this.canUpdate;
- },
- lockedOutUserWarningInReplies() {
- return this.addPadding && this.isLockedOutOrSignedOut;
- },
- timelineEntryClass() {
- return {
- 'timeline-entry gl-mb-3 note note-wrapper note-comment': true,
- 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this
- .lockedOutUserWarningInReplies,
- };
- },
timelineEntryInnerClass() {
return {
- 'timeline-entry-inner': true,
- 'gl-pb-3': this.addPadding,
+ 'timeline-entry-inner': this.isNewDiscussion,
};
},
timelineContentClass() {
@@ -141,8 +134,7 @@ export default {
},
parentClass() {
return {
- 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap': !this
- .isEditing,
+ 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing,
};
},
isProjectArchived() {
@@ -151,6 +143,18 @@ export default {
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
+ workItemState() {
+ return this.workItem?.state;
+ },
+ commentButtonText() {
+ return this.isNewDiscussion ? __('Comment') : __('Reply');
+ },
+ timelineEntryClass() {
+ return this.isNewDiscussion
+ ? 'timeline-entry note-form'
+ : // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix';
+ },
},
watch: {
autofocus: {
@@ -166,7 +170,6 @@ export default {
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', commentText);
-
try {
this.track('add_work_item_comment');
@@ -180,25 +183,56 @@ export default {
},
},
update(store, createNoteData) {
- if (createNoteData.data?.createNote?.errors?.length) {
+ const numErrors = createNoteData.data?.createNote?.errors?.length;
+
+ if (numErrors) {
+ const { errors } = createNoteData.data.createNote;
+
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557
+ // When a note only contains quick actions,
+ // additional "helpful" messages are embedded in the errors field.
+ // For instance, a note solely composed of "/assign @foobar" would
+ // return a message "Commands only Assigned @root." as an error on creation
+ // even though the quick action successfully executed.
+ if (
+ numErrors === 2 &&
+ errors[0].includes('Commands only') &&
+ errors[1].includes('Command names')
+ ) {
+ return;
+ }
+
throw new Error(createNoteData.data?.createNote?.errors[0]);
}
},
});
- clearDraft(this.autosaveKey);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * Once form is successfully submitted, emit replied event,
+ * mark isSubmitting to false and clear storage before hiding the form.
+ * This will restrict comment form to restore the value while textarea
+ * input triggered due to keyboard event meta+enter.
+ *
+ */
this.$emit('replied');
+ clearDraft(this.autosaveKey);
this.cancelEditing();
} catch (error) {
this.$emit('error', error.message);
Sentry.captureException(error);
+ } finally {
+ this.isSubmitting = false;
}
-
- this.isSubmitting = false;
},
cancelEditing() {
- this.isEditing = false;
+ this.isEditing = this.isNewDiscussion;
this.$emit('cancelEditing');
},
+ showReplyForm() {
+ this.isEditing = true;
+ this.$emit('startReplying');
+ },
},
};
</script>
@@ -212,9 +246,6 @@ export default {
:is-project-archived="isProjectArchived"
/>
<div v-else :class="timelineEntryInnerClass">
- <div class="timeline-avatar gl-float-left">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
- </div>
<div :class="timelineContentClass">
<div :class="parentClass">
<work-item-comment-form
@@ -223,15 +254,27 @@ export default {
:aria-label="__('Add a reply')"
:is-submitting="isSubmitting"
:autosave-key="autosaveKey"
+ :is-new-discussion="isNewDiscussion"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-state="workItemState"
+ :work-item-id="workItemId"
+ :autofocus="autofocus"
+ :comment-button-text="commentButtonText"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
/>
- <gl-button
+ <textarea
v-else
- class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
- @click="isEditing = true"
- >{{ __('Add a reply') }}</gl-button
- >
+ ref="textarea"
+ rows="1"
+ class="reply-placeholder-text-field gl-font-regular!"
+ data-testid="note-reply-textarea"
+ :placeholder="__('Reply')"
+ :aria-label="__('Reply to comment')"
+ @focus="showReplyForm"
+ @click="showReplyForm"
+ ></textarea>
</div>
</div>
</div>
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 a3ebd51f76d..f9f24366725 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,11 +1,22 @@
<script>
import { GlButton } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__, __ } from '~/locale';
-import { joinPaths } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ STATE_OPEN,
+ STATE_EVENT_REOPEN,
+ STATE_EVENT_CLOSE,
+ TRACKING_CATEGORY_SHOW,
+ i18n,
+} from '~/work_items/constants';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
export default {
constantOptions: {
@@ -15,8 +26,13 @@ export default {
GlButton,
MarkdownEditor,
},
+ mixins: [Tracking.mixin()],
inject: ['fullPath'],
props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
workItemType: {
type: String,
required: true,
@@ -44,20 +60,44 @@ export default {
required: false,
default: __('Comment'),
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemState: {
+ type: String,
+ required: false,
+ default: STATE_OPEN,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
+ updateInProgress: false,
};
},
computed: {
- markdownPreviewPath() {
- return joinPaths(
- '/',
- gon.relative_url_root || '',
- this.fullPath,
- `/preview_markdown?target_type=${this.workItemType}`,
- );
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_task_status',
+ property: `type_${this.workItemType}`,
+ };
},
formFieldProps() {
return {
@@ -67,11 +107,30 @@ export default {
name: 'work-item-add-or-edit-comment',
};
},
+ isWorkItemOpen() {
+ return this.workItemState === STATE_OPEN;
+ },
+ toggleWorkItemStateText() {
+ return this.isWorkItemOpen
+ ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() })
+ : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() });
+ },
+ cancelButtonText() {
+ return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel');
+ },
},
methods: {
setCommentText(newText) {
- this.commentText = newText;
- updateDraft(this.autosaveKey, this.commentText);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * While the form is saving using meta+enter,
+ * avoid updating the data which is cleared after form submission.
+ */
+ if (!this.isSubmitting) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ }
},
async cancelEditing() {
if (this.commentText && this.commentText !== this.initialValue) {
@@ -91,23 +150,68 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
+ async toggleWorkItemState() {
+ const input = {
+ id: this.workItemId,
+ stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN,
+ };
+
+ this.updateInProgress = true;
+
+ try {
+ this.track('updated_state');
+
+ const { mutation, variables } = getUpdateWorkItemMutation({
+ workItemParentId: this.workItemParentId,
+ input,
+ });
+
+ const { data } = await this.$apollo.mutate({
+ mutation,
+ variables,
+ });
+
+ const errors = data.workItemUpdate?.errors;
+
+ if (errors?.length) {
+ this.$emit('error', i18n.updateError);
+ }
+ } catch (error) {
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+
+ this.$emit('error', msg);
+ Sentry.captureException(error);
+ }
+
+ this.updateInProgress = false;
+ },
+ cancelButtonAction() {
+ if (this.isNewDiscussion) {
+ this.toggleWorkItemState();
+ } else {
+ this.cancelEditing();
+ }
+ },
},
};
</script>
<template>
- <div class="timeline-discussion-body">
- <div class="note-body">
+ <div class="timeline-discussion-body gl-overflow-visible!">
+ <div class="note-body gl-p-0! gl-overflow-visible!">
<form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
<markdown-editor
:value="commentText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
:form-field-props="formFieldProps"
+ :add-spacing-classes="false"
data-testid="work-item-add-comment"
class="gl-mb-3"
- autofocus
use-bottom-toolbar
+ supports-quick-actions
+ :autofocus="autofocus"
@input="setCommentText"
@keydown.meta.enter="$emit('submitForm', commentText)"
@keydown.ctrl.enter="$emit('submitForm', commentText)"
@@ -117,6 +221,7 @@ export default {
category="primary"
variant="confirm"
data-testid="confirm-button"
+ :disabled="!commentText.length"
:loading="isSubmitting"
@click="$emit('submitForm', commentText)"
>{{ commentButtonText }}
@@ -125,8 +230,9 @@ export default {
data-testid="cancel-button"
category="primary"
class="gl-ml-3"
- @click="cancelEditing"
- >{{ __('Cancel') }}
+ :loading="updateInProgress"
+ @click="cancelButtonAction"
+ >{{ cancelButtonText }}
</gl-button>
</form>
</div>
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 1e08fecaf3d..21fc8f99366 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
@@ -54,6 +54,25 @@ export default {
required: false,
default: false,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -117,8 +136,7 @@ export default {
this.isExpanded = !this.isExpanded;
},
threadKey(note) {
- /* eslint-disable @gitlab/require-i18n-strings */
- return `${note.id}-thread`;
+ return `${note.id}-thread`; // eslint-disable-line @gitlab/require-i18n-strings
},
onReplied() {
this.isExpanded = true;
@@ -142,7 +160,15 @@ export default {
:has-replies="hasReplies"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
:class="{ 'gl-mb-4': hasReplies }"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :work-item-id="workItemId"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@error="$emit('error', $event)"
@@ -167,6 +193,14 @@ export default {
:work-item-type="workItemType"
:is-modal="isModal"
:class="{ 'gl-mb-4': hasReplies }"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', note)"
@error="$emit('error', $event)"
@@ -186,6 +220,14 @@ export default {
:note="reply"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
@startReplying="showReplyForm"
@deleteNote="$emit('deleteNote', reply)"
@error="$emit('error', $event)"
@@ -204,6 +246,9 @@ export default {
:work-item-type="workItemType"
:sort-order="sortOrder"
:add-padding="true"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ @startReplying="showReplyForm"
@cancelEditing="hideReplyForm"
@replied="onReplied"
@replying="onReplying"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
index 07e25312f87..e7a80bf39fb 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
@@ -30,7 +30,9 @@ export default {
<template>
<li class="timeline-entry note note-wrapper discussion-filter-note">
- <div class="timeline-icon gl-display-none gl-lg-display-flex">
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
<gl-icon name="comment" />
</div>
<div class="timeline-content gl-pl-8">
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 dcb6557600e..b8911592f5d 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
@@ -1,27 +1,26 @@
<script>
-import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants';
+import Tracking from '~/tracking';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getWorkItemQuery } from '~/work_items/utils';
import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
name: 'WorkItemNoteThread',
- i18n: {
- moreActionsText: __('More actions'),
- deleteNoteText: __('Delete comment'),
- copyLinkText: __('Copy link'),
- },
components: {
TimelineEntryItem,
NoteBody,
@@ -29,15 +28,28 @@ export default {
NoteActions,
GlAvatar,
GlAvatarLink,
- GlDropdown,
- GlDropdownItem,
WorkItemCommentForm,
EditedAt,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
+ mixins: [Tracking.mixin()],
props: {
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
note: {
type: Object,
required: true,
@@ -61,6 +73,25 @@ export default {
required: false,
default: false,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -68,6 +99,13 @@ export default {
};
},
computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_note_actions',
+ property: `type_${this.workItemType}`,
+ };
+ },
author() {
return this.note.author;
},
@@ -111,6 +149,28 @@ export default {
hasAwardEmojiPermission() {
return this.note.userPermissions.awardEmoji;
},
+ isAuthorAnAssignee() {
+ return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length);
+ },
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ skip() {
+ return !this.queryVariables.id && !this.queryVariables.iid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
},
methods: {
showReplyForm() {
@@ -121,8 +181,8 @@ export default {
updateDraft(this.autosaveKey, this.note.body);
},
async updateNote(newText) {
- this.isEditing = false;
try {
+ this.isEditing = false;
await this.$apollo.mutate({
mutation: updateWorkItemNoteMutation,
variables: {
@@ -149,12 +209,68 @@ export default {
Sentry.captureException(error);
}
},
+ getNewAssigneesAndWidget() {
+ let newAssignees = [];
+ if (this.isAuthorAnAssignee) {
+ newAssignees = this.assignees.filter(({ id }) => id !== this.author.id);
+ } else {
+ newAssignees = [...this.assignees, this.author];
+ }
+
+ const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
+
+ const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget);
+
+ const editedWorkItemWidgets = [...this.workItem.widgets];
+
+ editedWorkItemWidgets[assigneesWidgetIndex] = {
+ ...editedWorkItemWidgets[assigneesWidgetIndex],
+ assignees: {
+ nodes: newAssignees,
+ },
+ };
+
+ return {
+ newAssignees,
+ editedWorkItemWidgets,
+ };
+ },
notifyCopyDone() {
if (this.isModal) {
navigator.clipboard.writeText(this.noteUrl);
}
toast(__('Link copied to clipboard.'));
},
+ async assignUserAction() {
+ const { newAssignees, editedWorkItemWidgets } = this.getNewAssigneesAndWidget();
+
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds: newAssignees.map(({ id }) => id),
+ },
+ },
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...this.workItem,
+ widgets: editedWorkItemWidgets,
+ },
+ },
+ },
+ });
+ this.track(`${this.isAuthorAnAssignee ? 'unassigned_user' : 'assigned_user'}`);
+ } catch (error) {
+ this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
@@ -179,6 +295,11 @@ export default {
:autosave-key="autosaveKey"
:initial-value="note.body"
:comment-button-text="__('Save comment')"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-id="workItemId"
+ :autofocus="isEditing"
+ class="gl-pl-3 gl-mt-3"
@cancelEditing="isEditing = false"
@submitForm="updateNote"
/>
@@ -199,32 +320,15 @@ export default {
:show-reply="showReply"
:show-edit="hasAdminPermission"
:note-id="note.id"
+ :is-author-an-assignee="isAuthorAnAssignee"
+ :show-assign-unassign="canSetWorkItemMetadata"
@startReplying="showReplyForm"
@startEditing="startEditing"
@error="($event) => $emit('error', $event)"
+ @notifyCopyDone="notifyCopyDone"
+ @deleteNote="$emit('deleteNote')"
+ @assignUser="assignUserAction"
/>
- <gl-dropdown
- v-gl-tooltip
- icon="ellipsis_v"
- text-sr-only
- right
- :text="$options.i18n.moreActionsText"
- :title="$options.i18n.moreActionsText"
- category="tertiary"
- no-caret
- >
- <gl-dropdown-item :data-clipboard-text="noteUrl" @click="notifyCopyDone">
- <span>{{ $options.i18n.copyLinkText }}</span>
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="hasAdminPermission"
- variant="danger"
- data-testid="delete-note-action"
- @click="$emit('deleteNote')"
- >
- {{ $options.i18n.deleteNoteText }}
- </gl-dropdown-item>
- </gl-dropdown>
</div>
</div>
<div class="timeline-discussion-body">
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 6bea7953698..624a532c2aa 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,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
@@ -10,11 +10,18 @@ export default {
name: 'WorkItemNoteActions',
i18n: {
editButtonText: __('Edit comment'),
+ moreActionsText: __('More actions'),
+ deleteNoteText: __('Delete comment'),
+ copyLinkText: __('Copy link'),
+ assignUserText: __('Assign to commenting user'),
+ unassignUserText: __('Unassign from commenting user'),
},
components: {
GlButton,
GlIcon,
ReplyButton,
+ GlDropdown,
+ GlDropdownItem,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
@@ -39,6 +46,28 @@ export default {
required: false,
default: false,
},
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isAuthorAnAssignee: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showAssignUnassign: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assignUserActionText() {
+ return this.isAuthorAnAssignee
+ ? this.$options.i18n.unassignUserText
+ : this.$options.i18n.assignUserText;
+ },
},
methods: {
async setAwardEmoji(name) {
@@ -100,5 +129,39 @@ export default {
:aria-label="$options.i18n.editButtonText"
@click="$emit('startEditing')"
/>
+ <gl-dropdown
+ v-gl-tooltip
+ data-testid="work-item-note-actions"
+ icon="ellipsis_v"
+ text-sr-only
+ right
+ :text="$options.i18n.moreActionsText"
+ :title="$options.i18n.moreActionsText"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item
+ data-testid="copy-link-action"
+ :data-clipboard-text="noteUrl"
+ @click="$emit('notifyCopyDone')"
+ >
+ <span>{{ $options.i18n.copyLinkText }}</span>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showAssignUnassign"
+ data-testid="assign-note-action"
+ @click="$emit('assignUser')"
+ >
+ {{ assignUserActionText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showEdit"
+ variant="danger"
+ data-testid="delete-note-action"
+ @click="$emit('deleteNote')"
+ >
+ {{ $options.i18n.deleteNoteText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index db36b4e1bbe..3f5ff526e91 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -1,11 +1,12 @@
<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlAlert,
GlButton,
+ GlLink,
},
props: {
error: {
@@ -42,7 +43,10 @@ export default {
</script>
<template>
- <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4">
+ <div
+ id="tasks"
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
+ >
<div
class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base"
:class="{
@@ -50,9 +54,15 @@ export default {
}"
>
<div class="gl-display-flex gl-flex-grow-1">
- <h5 class="gl-m-0 gl-line-height-24">
+ <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24">
+ <gl-link
+ id="user-content-tasks-links"
+ class="anchor position-absolute gl-text-decoration-none"
+ href="#tasks"
+ aria-hidden="true"
+ />
<slot name="header"></slot>
- </h5>
+ </h3>
<slot name="header-suffix"></slot>
</div>
<slot name="header-right"></slot>
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 3c56b627673..0e0c6bca802 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -2,33 +2,53 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
GlModalDirective,
+ GlToggle,
} from '@gitlab/ui';
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,
I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+ WIDGET_TYPE_NOTIFICATIONS,
} from '../constants';
+import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
export default {
i18n: {
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
+ notifications: s__('WorkItem|Notifications'),
+ notificationOn: s__('WorkItem|Notifications turned on.'),
+ notificationOff: s__('WorkItem|Notifications turned off.'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
+ GlToggle,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({ label: 'actions_menu' })],
+ isLoggedIn: isLoggedIn(),
+ notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ deleteActionTestId: TEST_ID_DELETE_ACTION,
props: {
workItemId: {
type: String,
@@ -60,8 +80,17 @@ export default {
required: false,
default: false,
},
+ subscribedToNotifications: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ initialSubscribed: this.subscribedToNotifications,
+ };
},
- emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
computed: {
i18n() {
return {
@@ -70,6 +99,16 @@ export default {
};
},
},
+ watch: {
+ subscribedToNotifications() {
+ /**
+ * To toggle the value if mutation fails, assign the
+ * subscribedToNotifications boolean value directly
+ * to data prop.
+ */
+ this.initialSubscribed = this.subscribedToNotifications;
+ },
+ },
methods: {
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
@@ -84,6 +123,56 @@ export default {
this.track('cancel_delete_work_item');
}
},
+ toggleNotifications(subscribed) {
+ const inputVariables = {
+ id: this.workItemId,
+ notificationsWidget: {
+ subscribed,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemNotificationsMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ id: this.workItemId,
+ widgets: [
+ {
+ type: WIDGET_TYPE_NOTIFICATIONS,
+ subscribed,
+ __typename: 'WorkItemWidgetNotifications',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ toast(
+ subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
+ );
+ },
+ )
+ .catch((error) => {
+ this.updateError = error.message;
+ this.$emit('error', error.message);
+ });
+ },
},
};
</script>
@@ -99,9 +188,27 @@ export default {
no-caret
right
>
+ <template v-if="$options.isLoggedIn">
+ <gl-dropdown-form
+ class="work-item-notifications-form"
+ :data-testid="$options.notificationsToggleFormTestId"
+ >
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ v-model="initialSubscribed"
+ :label="$options.i18n.notifications"
+ :data-testid="$options.notificationsToggleTestId"
+ label-position="left"
+ label-id="notifications-toggle"
+ @change="toggleNotifications($event)"
+ />
+ </div>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ </template>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
- data-testid="confidentiality-toggle-action"
+ :data-testid="$options.confidentialityTestId"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
@@ -114,7 +221,7 @@ export default {
<gl-dropdown-item
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
- data-testid="delete-action"
+ :data-testid="$options.deleteActionTestId"
variant="danger"
>{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
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 ddfaa376028..141dac9573c 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -55,7 +55,6 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
- descriptionHtml: '',
conflictedDescription: '',
formFieldProps: {
'aria-label': __('Description'),
@@ -81,12 +80,7 @@ export default {
},
result() {
if (this.isEditing) {
- if (this.descriptionText !== this.workItemDescription?.description) {
- this.conflictedDescription = this.workItemDescription?.description;
- }
- } else {
- this.descriptionText = this.workItemDescription?.description;
- this.descriptionHtml = this.workItemDescription?.descriptionHtml;
+ this.checkForConflicts();
}
},
error() {
@@ -148,6 +142,11 @@ export default {
},
},
methods: {
+ checkForConflicts() {
+ if (this.descriptionText !== this.workItemDescription?.description) {
+ this.conflictedDescription = this.workItemDescription?.description;
+ }
+ },
async startEditing() {
this.isEditing = true;
@@ -254,7 +253,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
enable-autocomplete
supports-quick-actions
- init-on-autofocus
+ autofocus
@input="setDescriptionText"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
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 ad7a54aaf16..06e8a65ecf7 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -26,6 +26,7 @@ import {
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_NOTIFICATIONS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
@@ -39,7 +40,7 @@ import {
WIDGET_TYPE_NOTES,
} from '../constants';
-import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
+import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
@@ -224,15 +225,18 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ canSetWorkItemMetadata() {
+ return this.workItem?.userPermissions?.setWorkItemMetadata;
+ },
+ canAssignUnassignUser() {
+ return this.workItemAssignees && this.canSetWorkItemMetadata;
+ },
confidentialTooltip() {
return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
},
fullPath() {
return this.workItem?.project.fullPath;
},
- workItemsMvcEnabled() {
- return this.glFeatures.workItemsMvc;
- },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -268,6 +272,9 @@ export default {
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
+ workItemNotificationsSubscribed() {
+ return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed);
+ },
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
@@ -534,14 +541,13 @@ export default {
{{ workItemBreadcrumbReference }}
</li>
</ul>
- <work-item-type-icon
- v-else-if="!error"
- :work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
- show-text
- class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
- data-testid="work-item-type"
- />
+ <div v-else-if="!error" class="gl-mr-auto" data-testid="work-item-type">
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
+ {{ workItemBreadcrumbReference }}
+ </div>
<gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
<gl-badge
v-if="workItem.confidential"
@@ -555,6 +561,7 @@ export default {
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
:can-delete="canDelete"
:can-update="canUpdate"
@@ -705,12 +712,17 @@ export default {
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
:query-variables="queryVariables"
:full-path="fullPath"
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
+ :is-modal="isModal"
+ :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
+ :can-set-work-item-metadata="canAssignUnassignUser"
class="gl-pt-5"
@error="updateError = $event"
+ @has-notes="updateHasNotes"
/>
<gl-empty-state
v-if="error"
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 730bdb4e7c7..51b957bb852 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
@@ -188,7 +188,7 @@ export default {
:work-item-parent-id="issueGid"
:work-item-id="displayedWorkItemId"
:work-item-iid="displayedWorkItemIid"
- class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
+ class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
index d119cdc2785..d1866110fd4 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -1,11 +1,13 @@
<script>
-import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
+import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
+import { isScopedLabel } from '~/lib/utils/common_utils';
import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
-
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import {
STATE_OPEN,
TASK_TYPE_NAME,
@@ -24,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
+ GlLabel,
GlLink,
GlButton,
GlIcon,
@@ -68,9 +71,18 @@ export default {
isExpanded: false,
children: [],
isLoadingChildren: false,
+ activeToast: null,
+ childrenBeforeRemoval: [],
+ hasChildren: false,
};
},
computed: {
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
@@ -113,9 +125,6 @@ export default {
this.childItem.iid
}?iid_path=true`;
},
- hasChildren() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren;
- },
chevronType() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
@@ -135,6 +144,17 @@ export default {
return false;
},
},
+ watch: {
+ childItem: {
+ handler(val) {
+ this.hasChildren = this.getWidgetByType(val, WIDGET_TYPE_HIERARCHY)?.hasChildren;
+ },
+ immediate: true,
+ },
+ children(val) {
+ this.hasChildren = val?.length > 0;
+ },
+ },
methods: {
toggleItem() {
this.isExpanded = !this.isExpanded;
@@ -166,6 +186,72 @@ export default {
this.isLoadingChildren = false;
}
},
+ showScopedLabel(label) {
+ return isScopedLabel(label) && this.allowsScopedLabels;
+ },
+ async removeChild(childId) {
+ this.cloneChildren();
+ this.isLoadingChildren = true;
+
+ try {
+ const { data } = await this.updateWorkItem(childId, null);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.filterRemovedChild(childId);
+
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, childId),
+ },
+ });
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while removing child.'), error);
+ Sentry.captureException(error);
+ this.restoreChildren();
+ } finally {
+ this.isLoadingChildren = false;
+ }
+ },
+ async undoChildRemoval(childId) {
+ this.isLoadingChildren = true;
+ try {
+ const { data } = await this.updateWorkItem(childId, this.childItem.id);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.activeToast?.hide();
+ this.restoreChildren();
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while undoing child removal.'), error);
+ Sentry.captureException(error);
+ } finally {
+ this.activeToast?.hide();
+ this.childrenBeforeRemoval = [];
+ this.isLoadingChildren = false;
+ }
+ },
+ async updateWorkItem(childId, parentId) {
+ return this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ });
+ },
+ cloneChildren() {
+ this.childrenBeforeRemoval = cloneDeep(this.children);
+ },
+ filterRemovedChild(childId) {
+ this.children = this.children.filter(({ id }) => id !== childId);
+ },
+ restoreChildren() {
+ this.children = [...this.childrenBeforeRemoval];
+ },
+ showAlert(message, error) {
+ createAlert({
+ message,
+ captureError: true,
+ error,
+ });
+ },
},
};
</script>
@@ -190,66 +276,72 @@ export default {
@click="toggleItem"
/>
<div
- class="work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-rounded-base"
- :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']"
+ class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base"
data-testid="links-child"
>
- <span
- :id="`stateIcon-${childItem.id}`"
- class="gl-cursor-help gl-mr-3 gl-line-height-32"
- :class="{ 'gl-display-flex': hasMetadata }"
- data-testid="item-status-icon"
- >
- <gl-icon
- class="gl-text-secondary"
- :class="iconClass"
- :name="iconName"
- :aria-label="stateTimestampTypeText"
- />
- </span>
- <div
- class="gl-display-flex gl-flex-grow-1"
- :class="{
- 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata,
- 'gl-align-items-center': !hasMetadata,
- }"
- >
- <div class="gl-display-flex">
- <rich-timestamp-tooltip
- :target="`stateIcon-${childItem.id}`"
- :raw-timestamp="stateTimestamp"
- :timestamp-type-text="stateTimestampTypeText"
+ <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
+ <div
+ class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
+ >
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <span
+ :id="`stateIcon-${childItem.id}`"
+ class="gl-cursor-help"
+ data-testid="item-status-icon"
+ >
+ <gl-icon
+ class="gl-text-secondary"
+ :class="iconClass"
+ :name="iconName"
+ :aria-label="stateTimestampTypeText"
+ />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <span v-if="childItem.confidential">
+ <gl-icon
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="__('Confidential')"
+ :title="__('Confidential')"
+ />
+ </span>
+ <gl-link
+ :href="childPath"
+ class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
+ data-testid="item-title"
+ @click="$emit('click', $event)"
+ @mouseover="$emit('mouseover')"
+ @mouseout="$emit('mouseout')"
+ >
+ {{ childItem.title }}
+ </gl-link>
+ </div>
+ <work-item-link-child-metadata
+ v-if="hasMetadata"
+ :metadata-widgets="metadataWidgets"
+ class="gl-ml-6 ml-xl-0"
/>
- <gl-icon
- v-if="childItem.confidential"
- v-gl-tooltip.top
- name="eye-slash"
- class="gl-mr-2 gl-text-orange-500"
- data-testid="confidential-icon"
- :aria-label="__('Confidential')"
- :title="__('Confidential')"
+ </div>
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
+ <gl-label
+ v-for="label in labels"
+ :key="label.id"
+ :title="label.title"
+ :background-color="label.color"
+ :description="label.description"
+ :scoped="showScopedLabel(label)"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
+ tooltip-placement="top"
/>
- <gl-link
- :href="childPath"
- class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold"
- data-testid="item-title"
- @click="$emit('click', $event)"
- @mouseover="$emit('mouseover')"
- @mouseout="$emit('mouseout')"
- >
- {{ childItem.title }}
- </gl-link>
</div>
- <work-item-link-child-metadata
- v-if="hasMetadata"
- :metadata-widgets="metadataWidgets"
- class="gl-mt-1"
- />
</div>
- <div
- v-if="canUpdate"
- class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"
- >
+ <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
<work-item-links-menu
:work-item-id="childItem.id"
:parent-work-item-id="issuableGid"
@@ -266,7 +358,7 @@ export default {
:work-item-id="issuableGid"
:work-item-type="workItemType"
:children="children"
- @removeChild="fetchChildren"
+ @removeChild="removeChild"
@click="$emit('click', $event)"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 80802cb3858..ddeac2b92ae 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -1,16 +1,14 @@
<script>
-import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
-import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants';
+import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants';
export default {
components: {
- GlLabel,
GlAvatar,
GlAvatarLink,
GlAvatarsInline,
@@ -33,12 +31,6 @@ export default {
assignees() {
return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || [];
},
- labels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
- },
- allowsScopedLabels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
- },
assigneesCollapsedTooltip() {
if (this.assignees.length > 2) {
return sprintf(s__('WorkItem|%{count} more assignees'), {
@@ -56,21 +48,16 @@ export default {
return '';
},
},
- methods: {
- showScopedLabel(label) {
- return isScopedLabel(label) && this.allowsScopedLabels;
- },
- },
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center">
+ <div class="gl-display-flex gl-md-justify-content-end gl-gap-3">
<slot></slot>
<item-milestone
v-if="milestone"
:milestone="milestone"
- class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
+ class="gl-display-flex gl-align-items-center gl-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
/>
<gl-avatars-inline
v-if="assignees.length"
@@ -81,7 +68,6 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
- class="gl-mr-5"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
@@ -89,18 +75,6 @@ export default {
</gl-avatar-link>
</template>
</gl-avatars-inline>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap">
- <gl-label
- v-for="label in labels"
- :key="label.id"
- :title="label.title"
- :background-color="label.color"
- :description="label.description"
- :scoped="showScopedLabel(label)"
- class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
- tooltip-placement="top"
- />
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
index fb3ed7af736..53e8eedf060 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -11,8 +11,13 @@ export default {
</script>
<template>
- <span class="gl-ml-5">
- <gl-dropdown category="tertiary" toggle-class="btn-icon btn-sm" :right="true">
+ <div class="gl-ml-5">
+ <gl-dropdown
+ category="tertiary"
+ toggle-class="btn-icon btn-sm"
+ :right="true"
+ data-testid="work_items_links_menu"
+ >
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
@@ -20,5 +25,5 @@ export default {
{{ s__('WorkItem|Remove') }}
</gl-dropdown-item>
</gl-dropdown>
- </span>
+ </div>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 97eaf2c0422..b72de98199e 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -186,7 +186,7 @@ export default {
</template>
<template #body>
<div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty">
- <p class="gl-mb-3">
+ <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500">
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
</p>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
index e233a2219fa..ba5c0794395 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
@@ -1,9 +1,4 @@
<script>
-import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
-
-import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-
export default {
components: {
WorkItemLinkChild: () => import('./work_item_link_child.vue'),
@@ -32,28 +27,11 @@ export default {
required: true,
},
},
- methods: {
- async updateWorkItem(childId) {
- try {
- await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
- });
- this.$emit('removeChild');
- } catch (error) {
- createAlert({
- message: s__('Hierarchy|Something went wrong while removing a child item.'),
- captureError: true,
- error,
- });
- }
- },
- },
};
</script>
<template>
- <div class="gl-ml-6">
+ <div class="gl-ml-6" data-testid="tree-children">
<work-item-link-child
v-for="child in children"
:key="child.id"
@@ -62,7 +40,7 @@ export default {
:issuable-gid="workItemId"
:child-item="child"
:work-item-type="workItemType"
- @removeChild="updateWorkItem"
+ @removeChild="$emit('removeChild', child.id)"
@click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
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 4ca8054fa5f..00cdc224320 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -1,6 +1,7 @@
<script>
import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants';
@@ -14,7 +15,11 @@ import {
WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
} from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
-import { getWorkItemNotesQuery } from '~/work_items/utils';
+import {
+ getWorkItemNotesQuery,
+ autocompleteDataSources,
+ markdownPreviewPath,
+} from '~/work_items/utils';
import {
updateCacheAfterCreatingNote,
updateCacheAfterDeletingNote,
@@ -48,6 +53,10 @@ export default {
type: String,
required: true,
},
+ workItemIid: {
+ type: String,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
@@ -70,6 +79,16 @@ export default {
required: false,
default: false,
},
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -78,6 +97,7 @@ export default {
sortOrder: ASC,
noteToDelete: null,
discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`),
};
},
computed: {
@@ -102,6 +122,12 @@ export default {
formAtTop() {
return this.sortOrder === DESC;
},
+ markdownPreviewPath() {
+ return markdownPreviewPath(this.fullPath, this.workItemIid);
+ },
+ autocompleteDataSources() {
+ return autocompleteDataSources(this.fullPath, this.workItemIid);
+ },
workItemCommentFormProps() {
return {
queryVariables: this.queryVariables,
@@ -110,6 +136,9 @@ export default {
fetchByIid: this.fetchByIid,
workItemType: this.workItemType,
sortOrder: this.sortOrder,
+ isNewDiscussion: true,
+ markdownPreviewPath: this.markdownPreviewPath,
+ autocompleteDataSources: this.autocompleteDataSources,
};
},
notesArray() {
@@ -252,6 +281,9 @@ export default {
filterDiscussions(filterValue) {
this.discussionFilter = filterValue;
},
+ updateKey() {
+ this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`);
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
// copied from discussions batch logic - every fetchMore call has a higher
@@ -335,12 +367,17 @@ export default {
</div>
<div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
<template v-if="!initialLoading">
- <ul class="notes main-notes-list timeline gl-clearfix!">
- <work-item-add-note
- v-if="formAtTop && !commentsDisabled"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
- />
+ <div v-if="formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
+ <ul class="notes main-notes-list timeline">
<template v-for="discussion in notesArray">
<system-note
v-if="isSystemNote(discussion)"
@@ -357,23 +394,31 @@ export default {
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
:is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
@deleteNote="showDeleteNoteModal($event, discussion)"
@error="$emit('error', $event)"
/>
</template>
</template>
- <work-item-add-note
- v-if="!formAtTop && !commentsDisabled"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
- />
-
<work-item-history-only-filter-note
v-if="commentsDisabled"
@changeFilter="filterDiscussions"
/>
</ul>
+ <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
</template>
<template v-if="showLoadingMoreSkeleton">
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index bbcf78e23aa..6af4f0fe790 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
@@ -205,3 +206,8 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [
{ key: DESC, text: __('Newest first'), testid: 'newest-first' },
{ key: ASC, text: __('Oldest first') },
];
+
+export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
+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';
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index fda71fabe22..40fb0fbc91d 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -15,6 +15,10 @@ extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
+extend type WorkItemPermissions {
+ setWorkItemMetadata: Boolean
+}
+
input LocalUserInput {
id: ID!
name: String
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
new file mode 100644
index 00000000000..f8952b62f28
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -0,0 +1,13 @@
+mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ workItem {
+ id
+ widgets {
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ }
+ }
+ }
+}
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 ada9f737e6e..86640a6d994 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -27,6 +27,7 @@ fragment WorkItem on WorkItem {
userPermissions {
deleteWorkItem
updateWorkItem
+ setWorkItemMetadata @client
}
widgets {
...WorkItemWidgets
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index b5d27231bef..44fda3ee894 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
+
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index bf8eafe3211..8039ef53f98 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 95709b36594..7c47e72e170 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -18,7 +18,7 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
- savedRepliesNewPath,
+ newCommentTemplatePath,
} = el.dataset;
return new Vue({
@@ -36,7 +36,7 @@ export const initWorkItemsRoot = () => {
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
- newSavedRepliesPath: savedRepliesNewPath,
+ newCommentTemplatePath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 1aa3baca165..ccb9d05bc90 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
// Zen Mode (full screen) textarea
//
/*= provides zen_mode:enter */
@@ -55,6 +53,7 @@ export default class ZenMode {
$(document).on('zen_mode:leave', () => {
this.exit();
});
+ // eslint-disable-next-line consistent-return
$(document).on('keydown', (e) => {
// Esc
if (e.keyCode === 27) {
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 1a998f89c68..483c4dc226b 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,12 +1,10 @@
@import './pages/colors';
@import './pages/commits';
-@import './pages/detail_page';
@import './pages/events';
@import './pages/groups';
@import './pages/hierarchy';
@import './pages/issues';
@import './pages/labels';
-@import './pages/login';
@import './pages/merge_requests';
@import './pages/note_form';
@import './pages/notes';
@@ -15,4 +13,3 @@
@import './pages/projects';
@import './pages/registry';
@import './pages/settings';
-@import './pages/storage_quota';
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss
index 7736f1012a5..de8142924f9 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/components/detail_page.scss
@@ -1,4 +1,5 @@
.detail-page-header {
+ padding-top: $gl-spacing-scale-4;
color: $gl-text-color;
line-height: 34px;
display: flex;
@@ -59,7 +60,7 @@
.detail-page-description {
.title {
- margin: 0 0 16px;
+ margin: 0 0 $gl-spacing-scale-4;
color: $gl-text-color;
padding: 0 0 0.3em;
border-bottom: 1px solid $white-dark;
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 0b30b4c3ef0..04a7590d531 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -55,16 +55,16 @@ $item-remove-button-space: 42px;
.item-weight .board-card-info-icon {
min-width: $gl-padding;
cursor: help;
+
+ &:focus {
+ @include gl-focus;
+ }
}
.confidential-icon {
color: $orange-500;
}
- .item-title-wrapper {
- max-width: calc(100% - #{$item-remove-button-space});
- }
-
.item-title {
flex-basis: 100%;
font-size: $gl-font-size-small;
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index c1c68f64d86..35c619a2e2f 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -1,5 +1,5 @@
.whats-new-drawer {
- margin-top: $header-height;
+ margin-top: calc(#{$header-height} + #{$calc-application-bars-height});
@include gl-shadow-none;
overflow-y: hidden;
width: 500px;
@@ -35,18 +35,6 @@
}
}
-.with-performance-bar .whats-new-drawer {
- margin-top: calc(#{$performance-bar-height} + #{$header-height});
-}
-
-.with-system-header .whats-new-drawer {
- margin-top: calc(#{$system-header-height} + #{$header-height});
-}
-
-.with-performance-bar.with-system-header .whats-new-drawer {
- margin-top: calc(#{$performance-bar-height} + #{$system-header-height} + #{$header-height});
-}
-
.whats-new-item-title-link {
&:hover,
&:focus,
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 799777977ed..cbdc55d66c1 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -29,10 +29,6 @@
border-bottom: 1px solid $white-dark;
color: $gl-text-color;
- &.oneline-block {
- line-height: 42px;
- }
-
&.white {
background-color: $white;
}
@@ -89,10 +85,6 @@
padding: $gl-padding 0;
border-bottom: 1px solid $white-dark;
- &.oneline-block {
- line-height: 36px;
- }
-
> .controls {
float: right;
}
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 27e9a041145..65e378a79f3 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -20,56 +20,3 @@
}
}
}
-
-.pika-single.gitlab-theme {
- .pika-label {
- color: $gl-text-color-secondary;
- font-size: 14px;
- font-weight: $gl-font-weight-normal;
- }
-
- th {
- padding: 2px 0;
- color: $note-disabled-comment-color;
- font-weight: $gl-font-weight-normal;
- text-transform: lowercase;
- border-top: 1px solid $calendar-border-color;
- }
-
- abbr {
- cursor: default;
- }
-
- td {
- border: 1px solid $calendar-border-color;
-
- &:first-child {
- border-left: 0;
- }
-
- &:last-child {
- border-right: 0;
- }
- }
-
- .pika-day {
- border-radius: 0;
- background-color: $white;
- text-align: center;
- }
-
- .is-today {
- .pika-day {
- color: inherit;
- font-weight: $gl-font-weight-normal;
- }
- }
-
- .is-selected .pika-day,
- .pika-day:hover,
- .is-today .pika-day {
- background: $gray-darker;
- color: $gl-text-color;
- box-shadow: none;
- }
-}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index cc7a45e1c82..d033a076832 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -1,3 +1,32 @@
+// stylelint-disable length-zero-no-unit
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+
+.with-performance-bar {
+ --performance-bar-height: #{$performance-bar-height};
+}
+
+.with-system-header {
+ --system-header-height: #{$system-header-height};
+}
+
+.with-top-bar {
+ --top-bar-height: #{$top-bar-height};
+}
+
+.with-system-footer {
+ --system-footer-height: #{$system-footer-height};
+}
+
+.review-bar-visible {
+ --mr-review-bar-height: #{$mr-review-bar-height};
+}
+
/** COLORS **/
.cgray { color: $gl-text-color; }
.clgray { color: $gray-200; }
@@ -253,12 +282,6 @@ li.note {
}
}
-img.emoji {
- height: 16px;
- vertical-align: top;
- width: 20px;
-}
-
.chart {
overflow: hidden;
height: 220px;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 1e05441c731..fb9816d1402 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -229,13 +229,13 @@
//
.nav-sidebar {
- @include gl-fixed;
- @include gl-bottom-0;
- @include gl-left-0;
+ position: fixed;
+ bottom: $calc-application-footer-height;
+ left: 0;
transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium;
z-index: 600;
width: $contextual-sidebar-width;
- top: $header-height;
+ top: $calc-application-header-height;
background-color: $contextual-sidebar-bg-color;
border-right: 1px solid $contextual-sidebar-border-color;
transform: translate3d(0, 0, 0);
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index cd0ea84cff4..ad09740583b 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -32,30 +32,12 @@
}
@media (min-width: map-get($grid-breakpoints, md)) {
- // The `+11` is to ensure the file header border shows when scrolled -
- // the bottom of the compare-versions header and the top of the file header
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
- --top: var(--initial-top);
-
- position: -webkit-sticky;
position: sticky;
- top: var(--top);
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height});
z-index: 120;
&.is-sidebar-moved {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px});
- }
-
- .with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar & {
- --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
- }
-
- .with-performance-bar & {
- top: calc(var(--initial-top) + #{$performance-bar-height});
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + 24px);
}
&::before {
@@ -70,19 +52,11 @@
}
&.is-commit {
- top: calc(#{$header-height} + #{$commit-stat-summary-height});
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$commit-stat-summary-height} + #{$performance-bar-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$commit-stat-summary-height});
}
&.is-compare {
- top: calc(#{$header-height} + #{$compare-branches-sticky-header-height});
-
- .with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$compare-branches-sticky-header-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$compare-branches-sticky-header-height});
}
}
@@ -99,22 +73,7 @@
@media (min-width: map-get($grid-breakpoints, md)) {
&.conflict .file-title,
&.conflict .file-title-flex-parent {
- top: $header-height;
- }
-
- .with-performance-bar &.conflict .file-title,
- .with-performance-bar &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-
- .with-system-header &.conflict .file-title,
- .with-system-header &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar &.conflict .file-title,
- .with-system-header.with-performance-bar &.conflict .file-title-flex-parent {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
+ top: $calc-application-header-height;
}
}
@@ -733,13 +692,9 @@ table.code {
@include media-breakpoint-up(sm) {
@include gl-sticky;
- top: $header-height;
+ top: $calc-application-header-height;
z-index: 200;
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-
&.is-stuck {
@include gl-py-0;
border-top: 1px solid $white-dark;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index b292adf9eac..e4025eb8b8d 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -2,14 +2,6 @@
* File content holder
*
*/
-.container-fluid.container-limited.limit-container-width {
- .file-holder.readme-holder.limited-width-container .file-content {
- max-width: $limited-layout-width;
- margin-left: auto;
- margin-right: auto;
- }
-}
-
.file-holder {
border: 1px solid $border-color;
border-top: 0;
@@ -484,18 +476,24 @@ span.idiff {
@include gl-display-none;
}
-.tree-list-scroll:not(.tree-list-blobs) {
+.mr-tree-list:not(.tree-list-blobs) {
.tree-list-parent::before {
@include gl-content-empty;
@include gl-absolute;
@include gl-z-index-1;
@include gl-pointer-events-none;
- top: 28px;
- left: calc(14px + (var(--level) * 16px));
- width: 1px;
- height: calc(100% - 24px);
- background-color: var(--gray-100, $gray-100);
+ top: -4px;
+ left: 0;
+ width: 100%;
+ bottom: -4px;
+ // The virtual scroller has a flat HTML structure so instead of the ::before
+ // element stretching over multiple rows we instead create a repeating background image
+ // for the line
+ background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px);
+ background-size: calc(var(--level) * 14px) 100%;
+ background-repeat: no-repeat;
+ background-position: 14px;
}
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 16c0a67f137..104cdf5544d 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -87,8 +87,8 @@
.filtered-search-term {
display: flex;
flex-shrink: 0;
- margin-top: 4px;
- margin-bottom: 4px;
+ margin-top: 2px;
+ margin-bottom: 2px;
.selectable {
display: flex;
@@ -195,7 +195,7 @@
display: flex;
width: 100%;
min-width: 0;
- border: 1px solid $border-color;
+ border: 1px solid $gray-400;
background-color: $white;
border-radius: $border-radius-default;
@@ -206,8 +206,7 @@
&.focus,
&.focus:hover {
- border-color: $blue-300;
- box-shadow: 0 0 4px $dropdown-input-focus-shadow;
+ @include gl-focus;
}
gl-emoji {
@@ -227,7 +226,7 @@
min-width: 200px;
padding-right: 25px;
padding-left: 0;
- height: $input-height;
+ height: #{$input-height - 2px};
line-height: inherit;
&,
@@ -261,7 +260,7 @@
flex: 1;
position: relative;
min-width: 0;
- height: 2rem;
+ height: #{$input-height - 2px};
background-color: $input-bg;
border-radius: $border-radius-default;
}
@@ -292,10 +291,11 @@
}
.filtered-search-history-dropdown-toggle-button.gl-button {
- border-radius: $border-radius-default 0 0 $border-radius-default;
- border-right: 1px solid $border-color;
- box-shadow: none;
+ $inner-border: #{$border-radius-default - 1px};
+ border-radius: $inner-border 0 0 $inner-border;
color: $gl-text-color-secondary;
+ margin: -1px 0 -1px -1px;
+ box-shadow: inset 0 0 0 1px $gray-400;
flex: 1;
transition: color 0.1s linear;
width: auto;
@@ -303,7 +303,6 @@
&:hover,
&:focus {
color: $gl-text-color;
- border-color: $border-color;
}
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index b63365e8159..6b4f1478978 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -9,7 +9,7 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
&.sticky {
position: sticky;
- top: $flash-container-top;
+ top: $calc-application-header-height;
z-index: 251;
.flash-alert,
@@ -114,17 +114,3 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
left: -50%;
}
}
-
-.with-system-header .flash-container.sticky {
- top: $flash-container-top + $system-header-height;
-}
-
-.with-performance-bar {
- .flash-container.sticky {
- top: $flash-container-top + $performance-bar-height;
- }
-
- &.with-system-header .flash-container.sticky {
- top: $flash-container-top + $performance-bar-height + $system-header-height;
- }
-}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index d1231da83d4..a5ff3c9c980 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -8,7 +8,7 @@ $search-input-field-x-min-width: 200px;
min-height: $header-height;
border: 0;
position: fixed;
- top: 0;
+ top: $calc-application-bars-height;
left: 0;
right: 0;
border-radius: 0;
@@ -312,21 +312,52 @@ $search-input-field-x-min-width: 200px;
margin-top: $dropdown-vertical-offset;
}
-.breadcrumbs {
- display: flex;
- min-height: $breadcrumb-min-height;
- color: $gl-text-color;
+.top-bar-container {
+ min-height: $top-bar-height;
}
-.breadcrumbs-container {
- display: flex;
- width: 100%;
- padding-top: $gl-padding / 2;
- padding-bottom: $gl-padding / 2;
- align-items: center;
+.top-bar-fixed {
+ background-color: $body-bg;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: $calc-application-bars-height;
+ width: auto;
+ z-index: $top-bar-z-index;
+ @include gl-inset-border-b-1-gray-100;
+
+ .breadcrumbs-list {
+ @include media-breakpoint-down(xs) {
+ flex-wrap: nowrap;
+ }
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: left $gl-transition-duration-medium, right $gl-transition-duration-medium;
+ }
+
+ @include media-breakpoint-up(md) {
+ .right-sidebar-collapsed & {
+ right: $gutter-collapsed-width;
+ }
+
+ .right-sidebar-expanded & {
+ right: $gutter-width;
+ }
+ }
+
+ @include media-breakpoint-up(xl) {
+ .page-with-super-sidebar & {
+ left: $super-sidebar-width;
+ }
+
+ .page-with-super-sidebar-collapsed & {
+ left: 0;
+ }
+ }
}
-.breadcrumbs-links {
+.breadcrumbs {
flex: 1;
min-width: 0;
align-self: center;
@@ -348,16 +379,6 @@ $search-input-field-x-min-width: 200px;
top: 1px;
}
}
-
- .dropdown-menu li a .identicon {
- width: 17px;
- height: 17px;
- font-size: $gl-font-size-xs;
- vertical-align: middle;
- text-indent: 0;
- line-height: $gl-font-size-xs + 2px;
- display: inline-block;
- }
}
.breadcrumbs-list {
@@ -498,11 +519,6 @@ $search-input-field-x-min-width: 200px;
visibility: visible;
}
-.with-performance-bar .navbar-gitlab,
-.with-performance-bar .fixed-top {
- top: $performance-bar-height;
-}
-
.navbar-empty {
justify-content: center;
height: $header-height;
@@ -558,7 +574,7 @@ $search-input-field-x-min-width: 200px;
@include media-breakpoint-down(sm) {
@include gl-display-block;
- + .breadcrumbs-links {
+ + .breadcrumbs {
@include gl-pl-4;
@include gl-border-l-1;
@include gl-border-l-solid;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index f27a36d1966..37a2264122d 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -74,7 +74,6 @@
}
.user-avatar-link {
- display: inline-block;
text-decoration: none;
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 7a92adf7b7b..23dbe440d33 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -35,8 +35,9 @@ body {
}
}
-.content-wrapper-margin {
- margin-top: $header-height;
+.layout-page {
+ padding-top: $calc-application-header-height;
+ padding-bottom: $calc-application-footer-height;
}
.content-wrapper {
@@ -142,11 +143,6 @@ body {
@include gl-overflow-hidden;
}
-
-.with-performance-bar .layout-page {
- margin-top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
.fullscreen-layout {
padding-top: 0;
height: 100vh;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index c40cadafb9c..48aacc9606e 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -120,9 +120,10 @@
}
.referenced-commands {
+ $radius: $border-radius-default - 1px;
background: $blue-50;
padding: $gl-padding-8 $gl-padding;
- border-radius: $border-radius-default;
+ border-radius: 0 0 $radius $radius;
p {
margin: 0;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index b20ec1dc50a..aefac300839 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -223,18 +223,6 @@
}
/*
-* Mixin that handles the position of sticky alerts at the top. It accounts for the performance bar
-*/
-// stylelint-disable-next-line length-zero-no-unit
-@mixin sticky-top-positioning($extra: 0px) {
- top: calc(#{$header-height} + #{$extra});
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$extra});
- }
-}
-
-/*
* Mixin that handles the container for the job logs (CI/CD and kubernetes pod logs)
*/
@mixin build-log($background: $black) {
@@ -268,14 +256,8 @@
@mixin build-log-top-bar($height) {
@include build-log-bar($height);
-
- position: -webkit-sticky;
position: sticky;
- top: $header-height;
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
+ top: $calc-application-header-height;
}
/*
diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss
index f11864f14af..84a34f12649 100644
--- a/app/assets/stylesheets/framework/page_title.scss
+++ b/app/assets/stylesheets/framework/page_title.scss
@@ -1,6 +1,6 @@
.page-title-holder {
.page-title {
- margin: $gl-padding 0;
+ margin: $gl-spacing-scale-4 0;
color: $gl-text-color;
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 7c3e346f4e6..946f2b28859 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -83,7 +83,7 @@
}
.right-sidebar {
- border-left: 1px solid $gray-100;
+ border-left: 1px solid $gray-50;
&.right-sidebar-merge-requests {
@include media-breakpoint-up(lg) {
@@ -271,6 +271,24 @@
}
}
+.merge-request-approved-icon {
+ animation: approval-animate 350ms ease-in;
+}
+
+@include keyframes(approval-animate) {
+ 0% {
+ transform: scale(0);
+ }
+
+ 75% {
+ transform: scale(1.4);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
.assignee-grid,
.reviewer-grid {
[data-css-area='attention'] {
@@ -288,21 +306,22 @@
@mixin right-sidebar {
position: fixed;
- top: $header-height;
- // Default value for CSS var must contain a unit
- // stylelint-disable-next-line length-zero-no-unit
- bottom: var(--review-bar-height, 0px);
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+ bottom: calc(#{$calc-application-footer-height} + var(--mr-review-bar-height));
right: 0;
transition: width $gl-transition-duration-medium;
background-color: $white;
z-index: 200;
overflow: hidden;
-
}
.right-sidebar {
&:not(.right-sidebar-merge-requests) {
@include right-sidebar;
+
+ @include media-breakpoint-down(sm) {
+ z-index: 251;
+ }
}
&.right-sidebar-merge-requests {
@@ -312,10 +331,6 @@
}
}
- @include media-breakpoint-down(sm) {
- z-index: 251;
- }
-
a:not(.btn) {
color: inherit;
@@ -469,28 +484,14 @@
padding: 0;
.issuable-context-form {
- --initial-top: calc(#{$header-height} + 76px);
- --top: var(--initial-top);
-
- @include gl-sticky;
- @include gl-overflow-auto;
+ $issue-sticky-header-height: 76px;
- top: var(--top);
- height: calc(100vh - var(--top));
+ 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));
+ position: sticky;
+ overflow: auto;
padding: 0 15px;
- margin-bottom: calc(var(--top) * -1);
-
- .with-performance-bar & {
- --top: calc(var(--initial-top) + #{$performance-bar-height});
- }
-
- .with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height});
- }
-
- .with-performance-bar.with-system-header & {
- --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
- }
+ margin-bottom: calc((#{$header-height} + $issue-sticky-header-height) * -1);
}
}
}
@@ -742,10 +743,6 @@
}
}
-.with-performance-bar .right-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
.issuable-show-labels {
.gl-label {
margin-bottom: 5px;
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
index f9e95d16f63..91781bfe539 100644
--- a/app/assets/stylesheets/framework/sortable.scss
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -51,3 +51,12 @@
cursor: no-drop !important;
}
}
+
+.tree-item.is-dragging {
+ border-top: 0;
+
+ .item-body {
+ background-color: $white;
+ border: 2px solid $gray-200;
+ }
+}
diff --git a/app/assets/stylesheets/framework/source_editor.scss b/app/assets/stylesheets/framework/source_editor.scss
index 046b8636f65..f1ee4c94942 100644
--- a/app/assets/stylesheets/framework/source_editor.scss
+++ b/app/assets/stylesheets/framework/source_editor.scss
@@ -41,6 +41,29 @@
}
.monaco-editor.gl-source-editor {
+ // Fix unreadable headings in tooltips for syntax highlighting themes that don't match general theme
+ &.vs-dark .markdown-hover {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ color: $source-editor-hover-light-text-color;
+ }
+ }
+
+ &.vs .markdown-hover {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ color: $source-editor-hover-dark-text-color;
+ }
+ }
+
.margin-view-overlays {
.line-numbers {
@include gl-display-flex;
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index 6b339f857cb..14eec335169 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -27,8 +27,8 @@
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(#{$header-height} + #{$calc-application-bars-height});
+ bottom: $calc-application-footer-height;
left: 0;
background-color: var(--gray-10, $gray-10);
border-right: 1px solid $t-gray-a-08;
@@ -36,10 +36,6 @@
width: $super-sidebar-width;
z-index: $super-sidebar-z-index;
- &:focus {
- @include gl-focus;
- }
-
&.super-sidebar-loading {
transform: translate3d(-100%, 0, 0);
@@ -49,7 +45,9 @@
}
&:not(.super-sidebar-loading) {
- transition: transform $gl-transition-duration-medium;
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform $gl-transition-duration-medium;
+ }
}
.user-bar {
@@ -78,8 +76,9 @@
}
}
- .counter .gl-icon {
- color: var(--gray-500, $gray-500);
+ .counter .gl-icon,
+ .item-icon {
+ color: var(--gray-600, $gray-500);
}
.counter:hover,
@@ -107,6 +106,9 @@
&[aria-expanded='true'] {
background-color: $t-gray-a-08;
}
+
+ &:focus {
+ @include gl-focus($inset: true); }
}
.btn-with-notification {
@@ -141,6 +143,29 @@
@include active-toggle;
}
}
+
+ .nav-item-link {
+ button,
+ .draggable-icon {
+ opacity: 0;
+ }
+
+ .draggable-icon {
+ cursor: grab;
+ }
+
+ &:hover {
+ button,
+ .draggable-icon {
+ opacity: 1;
+ }
+ }
+
+ &:focus button,
+ button:focus {
+ opacity: 1;
+ }
+ }
}
.super-sidebar-skip-to {
@@ -151,9 +176,25 @@
display: none;
}
+.super-sidebar-peek {
+ @include gl-shadow;
+ border-right: 0;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform 100ms !important;
+ }
+}
+
+.super-sidebar-hover-area {
+ z-index: $super-sidebar-z-index;
+}
+
.page-with-super-sidebar {
padding-left: 0;
- transition: padding-left $gl-transition-duration-medium;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: padding-left $gl-transition-duration-medium;
+ }
&:not(.page-with-super-sidebar-collapsed) {
.super-sidebar-overlay {
@@ -184,6 +225,10 @@
.page-with-super-sidebar-collapsed {
.super-sidebar {
transform: translate3d(-100%, 0, 0);
+
+ &.super-sidebar-peek {
+ transform: translate3d(0, 0, 0);
+ }
}
@include media-breakpoint-up(xl) {
@@ -195,19 +240,6 @@
}
}
-.container-limited .super-sidebar-toggle {
- @media (min-width: $super-sidebar-toggle-position-breakpoint) {
- position: absolute;
- left: $gl-spacing-scale-3;
- top: $gl-spacing-scale-3;
- margin: 0;
- }
-}
-
-.with-performance-bar .super-sidebar {
- top: $performance-bar-height;
-}
-
.gl-dark {
.super-sidebar {
.gl-new-dropdown-custom-toggle {
@@ -217,3 +249,38 @@
}
}
}
+
+.global-search-modal {
+ padding: 3rem 0.5rem 0;
+
+ &.gl-modal .modal-dialog {
+ align-items: flex-start;
+ }
+
+ @include gl-media-breakpoint-up(sm) {
+ padding: 5rem 1rem 0;
+ }
+
+ // This is a temporary workaround!
+ // the button in GitLab UI Search components need to be updated to not be the small size
+ // see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
+ .gl-search-box-by-type-clear.btn-sm {
+ padding: 0.5rem !important;
+ }
+
+ .is-searching {
+ .in-search-scope-help {
+ position: absolute;
+ top: 0.625rem;
+ right: 2.5rem;
+ }
+ }
+
+ .gl-search-box-by-type-input-borderless {
+ @include gl-rounded-base;
+ }
+
+ .global-search-results {
+ max-height: 30rem;
+ }
+}
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 590a66ff28e..946a241e6dd 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -36,57 +36,8 @@
}
}
-// System Header
-.with-system-header {
- // main navigation
- // login page
- .navbar-gitlab,
- .fixed-top {
- top: $system-header-height;
- }
-
- // left sidebar eg: project page
- // right sidebar eg: MR page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar {
- top: calc(#{$system-header-height} + #{$header-height});
- }
-
- .content-wrapper-margin {
- margin-top: calc(#{$system-header-height} + #{$header-height});
- }
-
- // Performance Bar
- // System Header
- &.with-performance-bar {
- // main navigation
- header.navbar-gitlab,
- .fixed-top {
- top: $performance-bar-height + $system-header-height;
- }
-
- .layout-page {
- margin-top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
- }
-
- // left sidebar eg: project page
- // right sidebar eg: MR page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height});
- }
- }
-}
-
// System Footer
.with-system-footer {
- // left sidebar eg: project page
- // right sidebar eg: mr page
- .nav-sidebar,
- .super-sidebar,
- .right-sidebar,
// navless pages' footer eg: login page
// navless pages' footer border eg: login page
&.devise-layout-html body .footer-container,
@@ -94,13 +45,9 @@
bottom: $system-footer-height;
}
- .content-wrapper-margin {
- margin-bottom: 16px;
- }
-
.boards-list,
.board-swimlanes {
- height: calc(100vh - (#{$header-height} + #{$breadcrumb-min-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32}));
+ height: calc(100vh - (#{$header-height} + #{$top-bar-height} + #{$performance-bar-height} + #{$system-footer-height} + #{$gl-padding-32}));
}
}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index a288701595e..b28a93749d1 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -160,3 +160,7 @@ table {
border-top: 0;
}
}
+
+.gl-table-no-top-border th {
+ border-top: 0;
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8edf5fc834a..88f990d2320 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -632,7 +632,7 @@ body {
}
.page-title {
- margin: 0 0 #{2 * $grid-size};
+ margin: $gl-spacing-scale-4 0;
line-height: 1.3;
&.with-button {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 0bc2e0583bb..dba9cafbd71 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -11,9 +11,9 @@ $contextual-sidebar-width: 256px;
$contextual-sidebar-collapsed-width: 56px;
$toggle-sidebar-height: 48px;
$super-sidebar-width: 256px;
-$super-sidebar-toggle-position-breakpoint: 1360px;
$super-sidebar-z-index: 600;
$super-sidebar-overlay-z-index: 599;
+$top-bar-z-index: 210;
/**
🚨 Do not use this spacing scale — it is deprecated and being removed. 🚨
@@ -390,7 +390,6 @@ $nav-active-bg: $t-gray-a-08;
* Text
*/
$gl-font-size: 14px;
-$gl-font-size-xs: 11px;
$gl-font-size-small: 12px;
$gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
@@ -481,10 +480,10 @@ $highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
$system-header-height: 16px;
$system-footer-height: $system-header-height;
+$mr-review-bar-height: calc(2rem + 13px);
$flash-height: 52px;
-$flash-container-top: 48px;
$context-header-height: 60px;
-$breadcrumb-min-height: 48px;
+$top-bar-height: 48px;
$home-panel-title-row-height: 64px;
$home-panel-avatar-mobile-size: 24px;
$issuable-title-max-width: 350px;
@@ -498,6 +497,14 @@ $gl-line-height-14: 14px;
$pages-group-name-color: #4c4e54;
/*
+ * Calculated heights
+ */
+$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height));
+$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height} + var(--top-bar-height));
+$calc-application-footer-height: var(--system-footer-height);
+$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height});
+
+/*
* Common component specific colors
*/
$user-mention-bg: rgba($blue-500, 0.044);
@@ -669,8 +676,6 @@ $note-targe3-inside: #ffffd3;
/*
* Calendar
*/
-$calendar-hover-bg: #ecf3fe;
-$calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
@@ -688,7 +693,7 @@ $issue-boards-filter-height: 68px;
The following heights are used in environment_logs.scss and are used for calculation of the log viewer height.
*/
$environment-logs-breadcrumbs-height: 63px;
-$environment-logs-breadcrumbs-height-md: $breadcrumb-min-height;
+$environment-logs-breadcrumbs-height-md: $top-bar-height;
$environment-logs-difference-xs-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height});
$environment-logs-difference-md-up: calc(#{$header-height} + #{$environment-logs-breadcrumbs-height-md});
@@ -734,7 +739,7 @@ $calendar-activity-colors: (
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
-$commit-stat-summary-height: 36px;
+$commit-stat-summary-height: 32px;
/*
* Files
@@ -908,13 +913,19 @@ $mr-tabs-height: 48px;
/*
Compare Branches
*/
-$compare-branches-sticky-header-height: 68px;
+$compare-branches-sticky-header-height: 32px;
/*
Board Swimlanes
*/
$board-swimlanes-headers-height: 64px;
+/*
+Source Editor theme overrides
+*/
+$source-editor-hover-light-text-color: #ececef;
+$source-editor-hover-dark-text-color: #333238;
+
/**
Bootstrap 4.2.0 introduced new icons for validating forms.
Our design system does not use those, so we are disabling them for now:
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index ccb5d96e966..969a6665634 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -192,8 +192,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
@@ -247,8 +247,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
@@ -269,8 +269,8 @@ pre.code,
}
&.hll {
- --highlight-border-color: #{$orange-200};
- background-color: $orange-50;
+ --highlight-border-color: #{$blue-300};
+ background-color: $blue-50;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 99e7f7ae0a4..f7ab78c1bcc 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -208,15 +208,11 @@
}
.boards-sidebar {
- top: $header-height !important;
+ top: $calc-application-header-height !important;
height: auto;
- bottom: 0;
+ bottom: $calc-application-footer-height;
padding-bottom: 0.5rem;
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height}) !important;
- }
-
.sidebar-collapsed-icon {
@include gl-display-none;
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index d40c03b7fd1..5114f484e53 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -6,30 +6,22 @@
}
.archived-job {
- top: $header-height;
+ top: $calc-application-header-height;
border-radius: 2px 2px 0 0;
color: var(--orange-600, $orange-600);
background-color: var(--orange-50, $orange-50);
border: 1px solid var(--border-color, $border-color);
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
}
.top-bar {
@include build-log-top-bar(50px);
&.has-archived-block {
- top: calc(#{$header-height} + 28px);
-
- .with-performance-bar & {
- top: calc(#{$header-height} + #{$performance-bar-height} + 28px);
- }
+ top: calc(#{$calc-application-header-height} + 28px);
}
&.affix {
- top: $header-height;
+ top: $calc-application-header-height;
// with sidebar
&.sidebar-expanded {
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
index 143682e1cd7..f56eb4ae6fb 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -20,10 +20,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-detail {
background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity);
-
- .with-performance-bar & {
- top: 35px;
- }
+ bottom: $calc-application-footer-height;
.comment-indicator {
border-radius: 50%;
diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss
index 79595fa3a98..e0fb95a1359 100644
--- a/app/assets/stylesheets/page_bundles/issuable.scss
+++ b/app/assets/stylesheets/page_bundles/issuable.scss
@@ -66,7 +66,7 @@
.title {
padding: 0;
- margin-bottom: $gl-padding;
+ margin-bottom: $gl-spacing-scale-4;
border-bottom: 0;
word-wrap: break-word;
overflow-wrap: break-word;
diff --git a/app/assets/stylesheets/page_bundles/issuable_list.scss b/app/assets/stylesheets/page_bundles/issuable_list.scss
index b08e129a805..1ca0c5e7ce6 100644
--- a/app/assets/stylesheets/page_bundles/issuable_list.scss
+++ b/app/assets/stylesheets/page_bundles/issuable_list.scss
@@ -18,6 +18,11 @@
}
}
+ .issuable-info,
+ .issuable-meta {
+ font-size: $gl-font-size-sm;
+ }
+
.issuable-meta {
display: flex;
flex-direction: column;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/page_bundles/login.scss
index 360ea20733d..495b7d58788 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/page_bundles/login.scss
@@ -1,4 +1,5 @@
-@import 'framework/variables';
+@import 'mixins_and_variables_and_functions';
+
/* Login Page */
.login-page {
.container {
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 396c590d912..97df87458ab 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -1,6 +1,5 @@
@import 'mixins_and_variables_and_functions';
-$mr-review-bar-height: calc(2rem + 13px);
$mr-widget-margin-left: 40px;
$mr-widget-min-height: 69px;
$tabs-holder-z-index: 250;
@@ -181,6 +180,10 @@ $tabs-holder-z-index: 250;
.content + .content {
@include gl-border-t;
}
+
+ .notes-content {
+ border: 0;
+ }
}
&.inline-diff-view {
@@ -242,18 +245,6 @@ $tabs-holder-z-index: 250;
}
}
-.with-system-header {
- --system-header-height: #{$system-header-height};
-}
-
-.with-performance-bar {
- --performance-bar-height: #{$performance-bar-height};
-}
-
-.review-bar-visible {
- --review-bar-height: #{$mr-review-bar-height};
-}
-
.diff-tree-list {
// This 11px value should match the additional value found in
// /assets/stylesheets/framework/diffs.scss
@@ -264,21 +255,19 @@ $tabs-holder-z-index: 250;
// If they don't match, the file tree and the diff files stick
// to the top at different heights, which is a bad-looking defect
$diff-file-header-top: 11px;
- --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
- --top-pos: var(--initial-pos);
- position: -webkit-sticky;
position: sticky;
- top: calc(var(--top-pos) + var(--performance-bar-height, 0px));
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
min-height: 300px;
- height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
+ height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top}));
.drag-handle {
bottom: 16px;
}
&.is-sidebar-moved {
- --top-pos: calc(var(--initial-pos) + 26px);
+ height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top} + 26px));
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top} + 26px);
}
}
@@ -915,15 +904,6 @@ $tabs-holder-z-index: 250;
&:not(:first-child) {
margin-top: $gl-padding;
}
-
- &:not(:last-child)::before {
- content: '';
- border-left: 2px solid var(--border-color, $border-color);
- position: absolute;
- bottom: -17px;
- left: 26px;
- height: 16px;
- }
}
.mr-version-controls {
@@ -992,8 +972,8 @@ $tabs-holder-z-index: 250;
.merge-request-overview {
@include media-breakpoint-up(lg) {
display: grid;
- grid-template-columns: calc(95% - 285px) auto;
- grid-gap: 5%;
+ grid-template-columns: calc(97% - 285px) auto;
+ grid-gap: 3%;
}
}
@@ -1156,7 +1136,7 @@ $tabs-holder-z-index: 250;
.review-bar-component {
position: fixed;
- bottom: 0;
+ bottom: $calc-application-footer-height;
left: 0;
z-index: $zindex-dropdown-menu;
display: flex;
@@ -1241,3 +1221,70 @@ $tabs-holder-z-index: 250;
}
}
}
+
+.mr-state-loader {
+ svg {
+ vertical-align: middle;
+ }
+
+ .gl-skeleton-loader {
+ max-width: 334px;
+ }
+}
+
+.mr-system-note-icon {
+ width: 20px;
+ height: 20px;
+ margin-left: 6px;
+
+ &.gl-bg-green-100 {
+ --bg-color: var(--green-100, #{$green-100});
+ }
+
+ &.gl-bg-red-100 {
+ --bg-color: var(--red-100, #{$red-100});
+ }
+
+ &.gl-bg-blue-100 {
+ --bg-color: var(--blue-100, #{$blue-100});
+ }
+}
+
+.mr-system-note-icon:not(.mr-system-note-empty)::before {
+ content: '';
+ display: block;
+ position: absolute;
+ left: calc(50% - 1px);
+ bottom: 100%;
+ width: 2px;
+ height: 20px;
+ background: linear-gradient(to bottom, transparent, var(--bg-color));
+
+ .system-note:first-child & {
+ display: none;
+ }
+}
+
+.mr-system-note-icon:not(.mr-system-note-empty)::after {
+ content: '';
+ display: block;
+ position: absolute;
+ left: calc(50% - 1px);
+ top: 100%;
+ width: 2px;
+ height: 20px;
+ background: linear-gradient(to bottom, var(--bg-color), transparent);
+
+ .system-note:last-child & {
+ display: none;
+ }
+}
+
+.mr-system-note-empty {
+ width: 8px;
+ height: 8px;
+ margin-top: 6px;
+ margin-left: 12px;
+ margin-right: 8px;
+ border: 2px solid var(--gray-50, $gray-50);
+}
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 708d1a2895e..8dc07715989 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -131,42 +131,6 @@
}
}
-.milestone-page-header {
- display: flex;
- flex-flow: row;
- align-items: center;
- flex-wrap: wrap;
-
- .milestone-buttons {
- margin-left: auto;
- order: 2;
-
- .verbose {
- display: none;
- }
- }
-
- .header-text-content {
- order: 3;
- width: 100%;
- }
-
- @include media-breakpoint-up(xs) {
- .milestone-buttons .verbose {
- display: inline;
- }
-
- .header-text-content {
- order: 2;
- width: auto;
- }
-
- .milestone-buttons {
- order: 3;
- }
- }
-}
-
.issuable-row {
background-color: var(--white, $white);
}
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index f08d6e3ca95..51bffd99dd0 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -57,10 +57,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-h-full;
@include gl-w-full;
@include gl-overflow-x-auto;
- @include gl-border-gray-100;
- @include gl-border-1;
- @include gl-border-solid;
- @include gl-rounded-base;
}
.timeline-section {
@@ -68,15 +64,12 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-top-0;
z-index: 20;
- .timeline-header-blank,
+ .timeline-header-label,
.timeline-header-item {
@include gl-float-left;
- height: $header-item-height;
- border-bottom: $border-style;
- background-color: var(--white, $white);
}
- .timeline-header-blank {
+ .timeline-header-label {
@include gl-sticky;
@include gl-top-0;
@include gl-left-0;
@@ -85,13 +78,8 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
.timeline-header-item {
- &:last-of-type .item-label {
- @include gl-border-r-0;
- }
-
- .item-label,
.item-sublabel .sublabel-value {
- color: var(--gray-400, $gray-400);
+ color: var(--gray-700, $gray-700);
@include gl-font-weight-normal;
&.label-dark {
@@ -103,11 +91,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
}
}
- .item-label {
- border-right: $border-style;
- border-bottom: $border-style;
- }
-
.item-sublabel {
@include gl-relative;
@include gl-display-flex;
@@ -118,7 +101,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
text-align: center;
@include gl-font-base;
- padding: 2px 0;
}
}
@@ -131,10 +113,15 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-rounded-full;
transform: translate(-50%, 50%);
}
+
+ &:first-of-type {
+ .week-item-sublabel .sublabel-value:nth-of-type(7) {
+ @include gl-border-r;
+ }
+ }
}
}
-.timeline-section .timeline-header-blank,
.list-section .details-cell {
&::after {
@include gl-h-full;
@@ -159,7 +146,6 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-left-0;
width: $details-cell-width;
@include gl-font-base;
- background-color: var(--white, $white);
z-index: 10;
}
@@ -182,3 +168,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
transform: translateX(-50%);
}
}
+
+.rotation-asignee-container {
+ overflow-x: clip;
+}
diff --git a/app/assets/stylesheets/pages/storage_quota.scss b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss
index 347bd1316c0..8f2cbc402c9 100644
--- a/app/assets/stylesheets/pages/storage_quota.scss
+++ b/app/assets/stylesheets/page_bundles/projects_usage_quotas.scss
@@ -1,3 +1,5 @@
+@import 'mixins_and_variables_and_functions';
+
.storage-type-usage {
&:first-child {
@include gl-rounded-top-left-base;
@@ -12,6 +14,6 @@
&:not(:last-child) {
@include gl-border-r-2;
@include gl-border-r-solid;
- @include gl-border-white;
+ border-right-color: var(--white, $white);
}
}
diff --git a/app/assets/stylesheets/page_bundles/releases.scss b/app/assets/stylesheets/page_bundles/releases.scss
index 24ffbf9b90c..c011ec3fe4c 100644
--- a/app/assets/stylesheets/page_bundles/releases.scss
+++ b/app/assets/stylesheets/page_bundles/releases.scss
@@ -10,3 +10,17 @@
min-height: 46px;
}
}
+
+.release-tag-selector {
+ .popover-body {
+ padding-left: 0;
+ padding-right: 0;
+ padding-bottom: 0;
+ min-width: $gl-dropdown-width;
+ max-width: $gl-dropdown-width;
+ }
+
+ .release-tag-list {
+ max-height: $dropdown-max-height;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/search.scss b/app/assets/stylesheets/page_bundles/search.scss
index cde570cfb0f..d37e87b5cd5 100644
--- a/app/assets/stylesheets/page_bundles/search.scss
+++ b/app/assets/stylesheets/page_bundles/search.scss
@@ -21,18 +21,18 @@ $border-radius-medium: 3px;
}
}
+.language-filter-checkbox {
+ .custom-control-label {
+ flex-grow: 1;
+ }
+}
+
.search-sidebar {
@include media-breakpoint-up(md) {
min-width: $search-sidebar-min-width;
max-width: $search-sidebar-max-width;
}
- .language-filter-checkbox {
- .custom-control-label {
- flex-grow: 1;
- }
- }
-
.language-filter-max-height {
max-height: $language-filter-max-height;
}
diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss
index d7d454bde45..69a3ec94fda 100644
--- a/app/assets/stylesheets/page_bundles/wiki.scss
+++ b/app/assets/stylesheets/page_bundles/wiki.scss
@@ -102,24 +102,42 @@
}
}
- .active > a {
- color: var(--black, $black);
- }
-
.active > .wiki-list {
a,
.wiki-list-expand-button,
.wiki-list-collapse-button {
- color: var(--black, $black);
+ color: $black;
+ }
+ }
+
+ .wiki-list {
+ height: $gl-spacing-scale-8;
+
+ &:hover {
+ background: $gray-10;
+
+ .wiki-list-create-child-button {
+ display: block;
+ box-shadow: none;
+
+ &:focus {
+ box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400;
+ }
+
+ &:active {
+ background: $gray-100 !important;
+ box-shadow: 0 0 0 1px #fff, 0 0 0 3px $blue-400;
+ }
+ }
}
}
.wiki-list-expand-button,
.wiki-list-collapse-button {
- color: var(--gray-400, $gray-400);
+ color: $gray-400;
&:hover {
- color: var(--black, $black);
+ color: $black;
}
}
@@ -130,10 +148,6 @@
margin: 0;
}
- ul.wiki-pages li {
- margin: 5px 0 10px;
- }
-
ul.wiki-pages ul {
padding-left: 20px;
}
@@ -172,6 +186,10 @@ ul.wiki-pages-list.content-list {
}
.wiki-list {
+ .wiki-list-create-child-button {
+ display: none;
+ }
+
.wiki-list-expand-button,
.wiki-list-collapse-button {
left: -$gl-spacing-scale-5;
@@ -198,17 +216,12 @@ ul.wiki-pages-list.content-list {
.drawio-editor {
position: fixed;
- top: calc(var(--header-height, 48px));
+ top: 0;
left: 0;
bottom: 0;
- width: 100%;
- height: calc(100% - var(--header-height, 48px));
+ width: 100vw;
+ height: 100vh;
border: 0;
z-index: 1100;
visibility: hidden;
}
-
-.with-performance-bar .drawio-editor {
- top: calc(var(--header-height, 48px) + 35px);
- height: calc(100% - var(--header-height, 48px) - 35px);
-}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 00c86c46ac8..ecbb872e1df 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -87,22 +87,20 @@
}
}
-.work-item-link-child {
- @include gl-border-1;
- @include gl-border-solid;
- @include gl-border-transparent;
- @include gl-rounded-base;
-
- &:hover,
- &:focus-within {
- @include gl-bg-white;
- @include gl-border-gray-50;
- }
-}
-
// sticky error placement for errors in modals , by default it is 83px for full view
#work-item-detail-modal {
.flash-container.flash-container-page.sticky {
top: -8px;
}
}
+
+
+.work-item-notifications-form {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 225c32c1989..83f51588f43 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -332,3 +332,18 @@
height: 100%;
}
}
+
+.add-review-item-modal {
+ .modal-content {
+ position: absolute;
+ top: 5%;
+ }
+
+ .title-hint-text {
+ color: $gl-text-color-secondary;
+ }
+
+ .gl-filtered-search-suggestion-list.dropdown-menu {
+ width: $gl-max-dropdown-max-height;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 0151446321a..9b6a3362e71 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -77,8 +77,7 @@ ul.related-merge-requests > li gl-emoji {
.issue {
&.closed,
&.merged {
- background: $gray-light;
- border-color: $border-color;
+ background: $gray-10;
}
}
@@ -243,6 +242,8 @@ ul.related-merge-requests > li gl-emoji {
}
&:hover > a.anchor::after {
+ position: relative;
+ top: -3px;
visibility: visible;
}
}
@@ -253,7 +254,7 @@ ul.related-merge-requests > li gl-emoji {
@include gl-left-0;
width: var(--width);
- top: $header-height;
+ top: $calc-application-header-height;
// collapsed right sidebar
@include media-breakpoint-up(sm) {
@@ -267,10 +268,6 @@ ul.related-merge-requests > li gl-emoji {
}
}
-.with-performance-bar .issue-sticky-header {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
@include media-breakpoint-up(md) {
// collapsed left sidebar + collapsed right sidebar
.page-with-contextual-sidebar .issue-sticky-header {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 5b8b850ba35..0a17b2c47a4 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -209,19 +209,11 @@ $comparison-empty-state-height: 62px;
}
.merge-request-tabs-holder {
- top: $header-height;
+ top: $calc-application-header-height;
z-index: $tabs-holder-z-index;
background-color: $body-bg;
border-bottom: 1px solid $border-color;
- .with-system-header & {
- top: calc(#{$header-height} + #{$system-header-height});
- }
-
- .with-system-header.with-performance-bar & {
- top: calc(#{$header-height} + #{$system-header-height} + #{$performance-bar-height});
- }
-
@include media-breakpoint-up(md) {
position: sticky;
}
@@ -240,12 +232,6 @@ $comparison-empty-state-height: 62px;
}
}
-.with-performance-bar {
- .merge-request-tabs-holder {
- top: calc(#{$header-height} + #{$performance-bar-height});
- }
-}
-
.limit-container-width {
.merge-request-tabs-container {
max-width: $limited-layout-width;
@@ -336,11 +322,7 @@ $comparison-empty-state-height: 62px;
.mr-compare {
.diff-file .file-title-flex-parent {
- top: calc(#{$header-height} + #{$mr-tabs-height});
-
- .with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height});
- }
+ top: calc(#{$calc-application-header-height} + #{$mr-tabs-height});
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 68a5176ad4b..b31ee069236 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -7,7 +7,7 @@ $system-note-icon-size: 1.5rem;
$system-note-svg-size: 1rem;
$icon-size-diff: $avatar-icon-size - $system-note-icon-size;
-$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 0.1rem;
+$system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 1.3rem;
$system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
@mixin vertical-line($left) {
@@ -15,10 +15,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
content: '';
border-left: 2px solid var(--gray-50, $gray-50);
position: absolute;
- top: $gl-padding-6;
+ top: 16px;
bottom: 0;
left: calc(#{$left} - 1px);
- height: calc(100% + 1.5rem);
+ height: calc(100% + 20px);
}
}
@@ -30,8 +30,30 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.issuable-discussion:not(.incident-timeline-events),
.limited-width-notes {
- .main-notes-list > li.timeline-entry:not(:last-of-type) {
- @include vertical-line(1rem);
+ .main-notes-list::before,
+ .timeline-entry:last-child::before {
+ content: '';
+ position: absolute;
+ width: 2px;
+ left: 15px;
+ top: 15px;
+ height: calc(100% - 15px);
+ }
+
+ .main-notes-list::before {
+ background: var(--gray-50, $gray-50);
+ }
+
+ .timeline-entry:last-child::before {
+ background: var(--white);
+
+ .gl-dark & {
+ background: var(--gray-10);
+ }
+
+ &.note-comment {
+ top: 30px;
+ }
}
}
@@ -63,6 +85,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
height: 2rem;
}
+ .gl-avatar {
+ border-color: var(--gray-50, $gray-50);
+ }
+
&.note-comment,
&.note-skeleton,
.draft-note {
@@ -265,7 +291,10 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
&.being-posted {
pointer-events: none;
- opacity: 0.5;
+
+ .timeline-entry-inner {
+ opacity: 0.5;
+ }
.dummy-avatar {
background-color: $gray-100;
@@ -343,6 +372,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.note-header-info {
padding-bottom: 0;
+ padding-top: 0;
}
&.timeline-entry::after {
@@ -369,9 +399,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
.timeline-content {
- @include notes-media('min', map-get($grid-breakpoints, sm)) {
- margin-left: 30px;
- }
+ margin-left: 30px;
}
.note-header {
@@ -450,40 +478,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
}
-
- .timeline-icon {
- float: left;
- }
-
- .system-note,
- .discussion-filter-note {
- .timeline-icon {
- display: flex;
- align-items: center;
- background-color: $gray-50;
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- border: 1px solid $gray-50;
- border-radius: $system-note-icon-size;
- margin: -$gl-spacing-scale-1 0 0 $gl-spacing-scale-2;
-
- svg {
- width: $system-note-svg-size;
- height: $system-note-svg-size;
- fill: $gray-600;
- display: block;
- margin: 0 auto;
- }
- }
- }
-
- .discussion-filter-note {
- .timeline-icon {
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- margin-top: -8px;
- }
- }
}
.card .notes {
@@ -493,7 +487,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
.timeline-icon {
- margin: 8px 0 0 14px;
+ margin: 20px 0 0 28px;
}
}
@@ -506,18 +500,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
border-radius: 0;
margin-left: 2.5rem;
- @media (min-width: map-get($grid-breakpoints, md)) {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
-
- &.is-sidebar-moved {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 24px});
- }
-
- .with-performance-bar & {
- --top: 123px;
- }
- }
-
&:hover {
background-color: $gray-light;
}
@@ -603,15 +585,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
.system-note {
background-color: transparent;
padding: 0;
-
- .timeline-icon {
- margin-top: -2px;
- }
-
- .timeline-entry-inner .timeline-icon {
- margin-top: $system-note-icon-m-top;
- margin-left: $system-note-icon-m-left;
- }
}
}
@@ -643,10 +616,6 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
padding: 0;
vertical-align: top;
white-space: normal;
-
- // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-foss/issues/53973
- // background-color is needed for dark code preference
- padding-bottom: 1px;
background-color: $white;
&.parallel {
@@ -673,6 +642,14 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio;
}
}
}
+
+ .diff-grid-comments:last-child {
+ .notes-content {
+ border-bottom-width: 0;
+ border-bottom-left-radius: #{$border-radius-default - 1px};
+ border-bottom-right-radius: #{$border-radius-default - 1px};
+ }
+ }
}
.diffs {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ee91d955019..e6c7e265cdb 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -291,9 +291,9 @@
.project-cell {
@include gl-display-table-cell;
- @include gl-border-b;
@include gl-vertical-align-top;
@include gl-py-4;
+ border-bottom: 1px solid $gray-50;
}
.project-row:last-of-type {
diff --git a/app/assets/stylesheets/pages/registry.scss b/app/assets/stylesheets/pages/registry.scss
index 31c6dbd2970..36b86771295 100644
--- a/app/assets/stylesheets/pages/registry.scss
+++ b/app/assets/stylesheets/pages/registry.scss
@@ -2,7 +2,7 @@
// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
//
// See app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue when this is changed.
-.breadcrumbs-container .gl-breadcrumbs {
+.breadcrumbs .gl-breadcrumbs {
padding: 0;
box-shadow: none;
}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 2d78ab82b7d..012ae4bb86a 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,23 +1,4 @@
-.integration-settings-form {
- .card.card-body,
- .info-well {
- padding: $gl-padding / 2;
- box-shadow: none;
- }
-
- .svg-container {
- max-width: 150px;
- }
-}
-
.visibility-level-setting {
- .option-title {
- font-weight: $gl-font-weight-normal;
- display: inline-block;
- color: var(--gl-text-color, $gl-text-color);
- vertical-align: top;
- }
-
.option-description,
.option-disabled-reason {
color: var(--gray-700, $gray-700);
@@ -69,30 +50,16 @@
}
}
-.push-pull-table {
- margin-top: 1em;
-}
-
.ci-variable-table,
.deploy-freeze-table,
.ci-secure-files-table {
table {
- thead {
- border-bottom: 1px solid var(--gray-50, $gray-50);
- }
-
tr {
td,
th {
padding-left: 0;
}
- th {
- background-color: transparent;
- font-weight: $gl-font-weight-bold;
- border: 0;
- }
-
// When tables are "stacked", restore td padding
@media(max-width: map-get($grid-breakpoints, lg)) {
td {
@@ -109,8 +76,8 @@
}
}
-.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap {
+.gl-md-flex-nowrap.gl-md-flex-nowrap {
@include gl-media-breakpoint-up(md) {
- @include gl-flex-wrap-nowrap;
+ @include gl-flex-nowrap;
}
}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 5024b082b99..cb153122767 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -128,6 +128,3 @@
color: $black;
}
-html.with-performance-bar .nav-sidebar {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 265f27f21fa..84181a00f34 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -63,9 +63,5 @@ a[href]::after {
}
.with-performance-bar .layout-page {
- margin-top: 0;
-}
-
-.content-wrapper-margin {
- margin-top: 0;
+ padding-top: 0;
}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index b4e896325d6..ef75c650853 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -140,18 +140,6 @@ kbd kbd {
background-color: #24232a;
opacity: 1;
}
-.form-inline {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
-}
-@media (min-width: 576px) {
- .form-inline .form-control {
- display: inline-block;
- width: auto;
- vertical-align: middle;
- }
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -562,17 +550,13 @@ strong {
svg {
vertical-align: baseline;
}
-.form-control,
-.search form {
+.form-control {
font-size: 0.875rem;
}
.hidden {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -593,6 +577,14 @@ svg {
html {
overflow-y: scroll;
}
+.layout-page {
+ padding-top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
+ padding-bottom: var(--system-footer-height);
+}
@media (min-width: 576px) {
.logged-out-marketing-header {
--header-height: 72px;
@@ -632,43 +624,22 @@ html {
color: #bfbfc3;
vertical-align: baseline;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+.with-top-bar {
+ --top-bar-height: 48px;
+}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb;
- outline: none;
-}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
-}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #333238;
- color: #ececef;
- font-size: 14px;
- text-align: left;
- border: 1px solid #535158;
- border-radius: 0.25rem;
- white-space: nowrap;
-}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
-}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-top: 7px;
- padding-bottom: 7px;
- padding-right: 25px;
- position: relative;
- text-overflow: ellipsis;
- line-height: 16px;
- width: 160px;
-}
.dropdown-menu {
display: none;
position: absolute;
@@ -750,11 +721,6 @@ html {
min-width: 100%;
}
}
-@media (max-width: 767.98px) {
- .dropdown-menu-toggle.dropdown-menu-toggle {
- width: 100%;
- }
-}
input {
border-radius: 0.25rem;
color: #ececef;
@@ -793,7 +759,7 @@ kbd {
min-height: var(--header-height, 48px);
border: 0;
position: fixed;
- top: 0;
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
left: 0;
right: 0;
border-radius: 0;
@@ -1075,11 +1041,15 @@ kbd {
}
.nav-sidebar {
position: fixed;
- bottom: 0;
+ bottom: var(--system-footer-height);
left: 0;
z-index: 600;
width: 256px;
- top: var(--header-height, 48px);
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
background-color: #1f1e24;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
@@ -1496,8 +1466,11 @@ kbd {
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height))
+ );
+ bottom: var(--system-footer-height);
left: 0;
background-color: var(--gray-10, #1f1e24);
border-right: 1px solid rgba(251, 250, 253, 0.08);
@@ -1513,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
@@ -1667,7 +1644,6 @@ svg.s16 {
--gray-200: #535158;
--gray-700: #bfbfc3;
--gray-900: #ececef;
- --gl-text-color: #ececef;
--border-color: #434248;
--white: #333238;
--black: #fff;
@@ -1779,16 +1755,6 @@ body.gl-dark .header-search .keyboard-shortcut-helper {
color: #ececef;
background-color: rgba(236, 236, 239, 0.2);
}
-body.gl-dark .search form {
- background-color: rgba(236, 236, 239, 0.2);
-}
-body.gl-dark .search .search-input::placeholder {
- color: rgba(236, 236, 239, 0.8);
-}
-body.gl-dark .search .search-input-wrap .search-icon,
-body.gl-dark .search .search-input-wrap .clear-icon {
- fill: rgba(236, 236, 239, 0.8);
-}
body.gl-dark .nav-sidebar li.active > a {
color: #ececef;
}
@@ -1817,17 +1783,6 @@ body.gl-dark .navbar-gitlab .header-search:active {
background-color: var(--gray-100) !important;
box-shadow: inset 0 0 0 1px var(--blue-200) !important;
}
-body.gl-dark .navbar-gitlab .search form {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--border-color);
-}
-body.gl-dark .navbar-gitlab .search form:active {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--blue-200);
-}
-body.gl-dark .navbar-gitlab .search form .search-input {
- color: var(--gl-text-color);
-}
.tab-width-8 {
tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 0a0fa83ff67..0dfc6be356f 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -140,18 +140,6 @@ kbd kbd {
background-color: #fbfafd;
opacity: 1;
}
-.form-inline {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
-}
-@media (min-width: 576px) {
- .form-inline .form-control {
- display: inline-block;
- width: auto;
- vertical-align: middle;
- }
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -562,17 +550,13 @@ strong {
svg {
vertical-align: baseline;
}
-.form-control,
-.search form {
+.form-control {
font-size: 0.875rem;
}
.hidden {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
.badge:not(.gl-badge) {
padding: 4px 5px;
font-size: 12px;
@@ -593,6 +577,14 @@ svg {
html {
overflow-y: scroll;
}
+.layout-page {
+ padding-top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
+ padding-bottom: var(--system-footer-height);
+}
@media (min-width: 576px) {
.logged-out-marketing-header {
--header-height: 72px;
@@ -632,43 +624,22 @@ html {
color: #535158;
vertical-align: baseline;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
+.with-top-bar {
+ --top-bar-height: 48px;
+}
.gl-font-sm {
font-size: 12px;
}
.dropdown {
position: relative;
}
-.dropdown-menu-toggle:active {
- box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc;
- outline: none;
-}
-.search-input-container .dropdown-menu {
- margin-top: 11px;
-}
-.dropdown-menu-toggle {
- padding: 6px 8px 6px 10px;
- background-color: #fff;
- color: #333238;
- font-size: 14px;
- text-align: left;
- border: 1px solid #bfbfc3;
- border-radius: 0.25rem;
- white-space: nowrap;
-}
-.dropdown-menu-toggle.no-outline {
- outline: 0;
-}
-.dropdown-menu-toggle.dropdown-menu-toggle {
- justify-content: flex-start;
- overflow: hidden;
- padding-top: 7px;
- padding-bottom: 7px;
- padding-right: 25px;
- position: relative;
- text-overflow: ellipsis;
- line-height: 16px;
- width: 160px;
-}
.dropdown-menu {
display: none;
position: absolute;
@@ -750,11 +721,6 @@ html {
min-width: 100%;
}
}
-@media (max-width: 767.98px) {
- .dropdown-menu-toggle.dropdown-menu-toggle {
- width: 100%;
- }
-}
input {
border-radius: 0.25rem;
color: #333238;
@@ -793,7 +759,7 @@ kbd {
min-height: var(--header-height, 48px);
border: 0;
position: fixed;
- top: 0;
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
left: 0;
right: 0;
border-radius: 0;
@@ -1075,11 +1041,15 @@ kbd {
}
.nav-sidebar {
position: fixed;
- bottom: 0;
+ bottom: var(--system-footer-height);
left: 0;
z-index: 600;
width: 256px;
- top: var(--header-height, 48px);
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
background-color: #fbfafd;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
@@ -1496,8 +1466,11 @@ kbd {
display: flex;
flex-direction: column;
position: fixed;
- top: 0;
- bottom: 0;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height))
+ );
+ bottom: var(--system-footer-height);
left: 0;
background-color: var(--gray-10, #fbfafd);
border-right: 1px solid rgba(31, 30, 36, 0.08);
@@ -1513,9 +1486,13 @@ kbd {
transform: translate3d(0, 0, 0);
}
}
+@media (prefers-reduced-motion: no-preference) {
+}
.page-with-super-sidebar {
padding-left: 0;
}
+@media (prefers-reduced-motion: no-preference) {
+}
@media (min-width: 1200px) {
.page-with-super-sidebar {
padding-left: 256px;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 57f61508178..cd768c3bbc0 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -430,8 +430,13 @@ input.btn-block[type="button"] {
cursor: not-allowed;
color: #89888d;
}
+.gl-form-checkbox.custom-control {
+ padding-left: 1rem;
+}
.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label {
cursor: pointer;
+ padding-left: 0.5rem;
+ margin-bottom: 0.5rem;
}
.gl-form-checkbox.custom-control
.custom-control-input
@@ -440,6 +445,7 @@ input.btn-block[type="button"] {
.custom-control-input
~ .custom-control-label::after {
top: 0;
+ left: -1rem;
}
.gl-form-checkbox.custom-control
.custom-control-input
@@ -663,6 +669,13 @@ body.navless {
.btn-block.btn {
padding: 6px 0;
}
+:root {
+ --performance-bar-height: 0px;
+ --system-header-height: 0px;
+ --top-bar-height: 0px;
+ --system-footer-height: 0px;
+ --mr-review-bar-height: 0px;
+}
.tab-content {
overflow: visible;
}
@@ -690,7 +703,11 @@ hr {
}
.flash-container.sticky {
position: sticky;
- top: 48px;
+ top: calc(
+ var(--header-height, 48px) +
+ calc(var(--system-header-height) + var(--performance-bar-height)) +
+ var(--top-bar-height)
+ );
z-index: 251;
}
.flash-container.flash-container-page {
@@ -756,223 +773,10 @@ input:-ms-input-placeholder {
svg {
fill: currentColor;
}
-.login-page .container {
- max-width: 960px;
-}
-.login-page .navbar-gitlab .container {
- max-width: none;
-}
-.login-page .flash-container {
- margin-bottom: 16px;
- position: relative;
- top: 8px;
-}
-.login-page .brand-holder {
- font-size: 18px;
- line-height: 1.5;
-}
-.login-page .brand-holder p {
- font-size: 16px;
- color: #888;
-}
-.login-page .brand-holder h3 {
- font-size: 22px;
-}
-.login-page .brand-holder img {
- max-width: 100%;
- margin-bottom: 30px;
-}
-.login-page .brand-holder a {
- font-weight: 600;
-}
-.login-page p {
- font-size: 13px;
-}
-.login-page .signin-text p {
- margin-bottom: 0;
- line-height: 1.5;
-}
-.login-page .borderless .login-box,
-.login-page .borderless .omniauth-container {
- box-shadow: none;
-}
-.login-page .borderless .g-recaptcha > div {
- margin-left: auto;
- margin-right: auto;
-}
-.login-page .login-box,
-.login-page .omniauth-container {
- box-shadow: 0 0 0 1px #dcdcde;
- border-radius: 0.25rem;
-}
-.login-page .login-box .login-heading h3,
-.login-page .omniauth-container .login-heading h3 {
- font-weight: 400;
- line-height: 1.5;
- margin: 0 0 10px;
-}
-.login-page .login-box .login-footer,
-.login-page .omniauth-container .login-footer {
- margin-top: 10px;
-}
-.login-page .login-box .login-footer p:last-child,
-.login-page .omniauth-container .login-footer p:last-child {
- margin-bottom: 0;
-}
-.login-page .login-box a.forgot,
-.login-page .omniauth-container a.forgot {
- float: right;
- padding-top: 6px;
-}
-.login-page .login-box .nav .active a,
-.login-page .omniauth-container .nav .active a {
- background: transparent;
-}
-.login-page .login-box .login-body,
-.login-page .omniauth-container .login-body {
- font-size: 13px;
-}
-.login-page .login-box .login-body input + p,
-.login-page .login-box .login-body input ~ p.field-validation,
-.login-page .omniauth-container .login-body input + p,
-.login-page .omniauth-container .login-body input ~ p.field-validation {
- margin-top: 5px;
-}
-.login-page .login-box .login-body .username .validation-success,
-.login-page .omniauth-container .login-body .username .validation-success {
- color: #217645;
-}
-.login-page .login-box .login-body .username .validation-error,
-.login-page .omniauth-container .login-body .username .validation-error {
- color: #dd2b0e;
-}
-.login-page .omniauth-container {
- border-radius: 0.25rem;
- font-size: 13px;
-}
-.login-page .omniauth-container p {
- margin: 0;
-}
-.login-page .omniauth-container form {
- padding: 0;
- border: 0;
- background: none;
-}
-.login-page .new-session-tabs {
- display: flex;
- box-shadow: 0 0 0 1px #dcdcde;
- border-top-right-radius: 4px;
- border-top-left-radius: 4px;
-}
-.login-page .new-session-tabs.nav-links-unboxed {
- border-color: transparent;
- box-shadow: none;
-}
-.login-page .new-session-tabs.nav-links-unboxed .nav-item {
- border-left: 0;
- border-right: 0;
- border-bottom: 1px solid #dcdcde;
- background-color: transparent;
-}
-.login-page .new-session-tabs.custom-provider-tabs {
- flex-wrap: wrap;
-}
-.login-page .new-session-tabs.custom-provider-tabs li {
- min-width: 85px;
- flex-basis: auto;
-}
-.login-page .new-session-tabs.custom-provider-tabs li:nth-child(n + 5) {
- border-top: 1px solid #dcdcde;
-}
-.login-page .new-session-tabs.custom-provider-tabs a {
- font-size: 16px;
-}
-.login-page .new-session-tabs li {
- flex: 1;
- text-align: center;
- border-left: 1px solid #dcdcde;
-}
-.login-page .new-session-tabs li:first-of-type {
- border-left: 0;
- border-top-left-radius: 4px;
-}
-.login-page .new-session-tabs li:last-of-type {
- border-top-right-radius: 4px;
-}
-.login-page .new-session-tabs li:not(.active) {
- background-color: #fbfafd;
-}
-.login-page .new-session-tabs li a {
- width: 100%;
- font-size: 18px;
-}
-.login-page .new-session-tabs li.active > a {
- cursor: default;
-}
-.login-page .form-control:active,
-.login-page .form-control:focus {
- background-color: #fff;
-}
-.login-page .submit-container {
- margin-top: 16px;
-}
-.login-page input[type="submit"] {
- margin-bottom: 0;
- display: block;
- width: 100%;
-}
-.login-page .devise-errors h2 {
- margin-top: 0;
- font-size: 14px;
- color: #ae1800;
-}
-@media (max-width: 575.98px) {
- .login-page .col-md-5.float-right {
- float: none !important;
- margin-bottom: 45px;
- }
-}
-.devise-layout-html {
- margin: 0;
- padding: 0;
- height: 100%;
-}
-.devise-layout-html body {
- height: calc(100% - 51px);
- margin: 0;
- padding: 0;
-}
-.devise-layout-html body.navless {
- height: calc(100% - 11px);
-}
-.devise-layout-html body .page-wrap {
- min-height: 100%;
- position: relative;
-}
-.devise-layout-html body .footer-container,
-.devise-layout-html body hr.footer-fixed {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- height: 40px;
- background: #fff;
-}
-.devise-layout-html body .login-page-broadcast {
- margin-top: 40px;
-}
-.devise-layout-html body .navless-container {
- padding: 0 15px 65px;
-}
-.devise-layout-html body .flash-container {
- padding-bottom: 65px;
-}
-@media (max-width: 575.98px) {
- .devise-layout-html body .flash-container {
- padding-bottom: 0;
- }
-}
+.fixed-top {
+ top: calc(var(--system-header-height) + var(--performance-bar-height));
+}
.gl-display-flex {
display: flex;
}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index f37b426cd91..6e46100dbb3 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -159,6 +159,22 @@
background-color: $search-and-nav-links-a30 !important;
}
+ &.is-focused {
+ input {
+ background-color: $white;
+ color: $gl-text-color !important;
+ box-shadow: inset 0 0 0 1px $gray-900;
+
+ &:focus {
+ box-shadow: inset 0 0 0 1px $gray-900, 0 0 0 1px $white, 0 0 0 3px $blue-400;
+ }
+
+ &::placeholder {
+ color: $gray-400;
+ }
+ }
+ }
+
svg.gl-search-box-by-type-search-icon {
color: $search-and-nav-links-a80;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 11f73b592fc..e5f99879166 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -39,6 +39,12 @@
.border-radius-small { border-radius: $border-radius-small; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
+// Override Bootstrap class with offset for system-header and
+// performance bar when present
+.fixed-top {
+ top: $calc-application-bars-height;
+}
+
.gl-children-ml-sm-3 > * {
@include media-breakpoint-up(sm) {
@include gl-ml-3;
@@ -71,58 +77,11 @@
// https://gitlab.com/groups/gitlab-org/-/epics/2882
.gl-h-200\! { height: px-to-rem($grid-size * 25) !important; }
-.gl-bg-purple-light { background-color: $purple-light; }
-
-// move this to GitLab UI once onboarding experiment is considered a success
-.gl-py-8 {
- padding-top: $gl-spacing-scale-8;
- padding-bottom: $gl-spacing-scale-8;
-}
-
-.gl-transition-property-stroke-opacity {
- transition-property: stroke-opacity;
-}
-
-.gl-transition-property-stroke {
- transition-property: stroke;
-}
-
-.gl-top-66vh {
- top: 66vh;
-}
-
-.gl-shadow-x0-y0-b3-s1-blue-500 {
- box-shadow: inset 0 0 3px $gl-border-size-1 $blue-500;
-}
-
// This utility is used to force the z-index to match that of dropdown menu's
.gl-z-dropdown-menu\! {
z-index: $zindex-dropdown-menu !important;
}
-.gl-flex-basis-quarter {
- flex-basis: 25%;
-}
-
-// Will be moved to @gitlab/ui (without the !important) in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1462
-// We only need the bang (!) version until the non-bang version is added to
-// @gitlab/ui utitlities.scss. Once there, it will get loaded in the correct
-// order to properly override `.gl-mt-6` which is used for narrower screen
-// widths (currently that style gets added to the application.css stylesheet
-// after this one, so it takes precedence).
-.gl-md-mt-11\! {
- @media (min-width: $breakpoint-md) {
- margin-top: $gl-spacing-scale-11 !important;
- }
-}
-
-// Same as above (also without the !important) but for overriding `.gl-pt-6`
-.gl-md-pt-11\! {
- @media (min-width: $breakpoint-md) {
- padding-top: $gl-spacing-scale-11 !important;
- }
-}
-
// This is used to help prevent issues with margin collapsing.
// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing.
.gl-force-block-formatting-context::after {
@@ -130,34 +89,6 @@
display: flex;
}
-.gl-sm-mr-3 {
- @include media-breakpoint-up(sm) {
- margin-right: $gl-spacing-scale-3;
- }
-}
-
-.gl-xl-ml-3 {
- @include media-breakpoint-up(lg) {
- margin-left: $gl-spacing-scale-3;
- }
-}
-
-.gl-mr-n2 {
- margin-right: -$gl-spacing-scale-2;
-}
-
-.gl-w-grid-size-30 {
- width: $grid-size * 30;
-}
-
-.gl-w-grid-size-40 {
- width: $grid-size * 40;
-}
-
-.gl-max-w-50p {
- max-width: 50%;
-}
-
/**
Note: ::-webkit-scrollbar is a non-standard rule only
supported by webkit browsers.
@@ -181,59 +112,6 @@
@include gl-focus($gl-border-size-1, $gray-900, true);
}
-/*
-All of the following (up until the "End gitlab-ui#1709" comment) will be moved
-to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
-*/
-.gl-md-grid-template-columns-3 {
- @include media-breakpoint-up(md) {
- grid-template-columns: repeat(3, 1fr);
- }
-}
-
-.gl-lg-grid-template-columns-4 {
- @include media-breakpoint-up(lg) {
- grid-template-columns: repeat(4, 1fr);
- }
-}
-
-.gl-max-w-48 {
- max-width: $gl-spacing-scale-48;
-}
-
-.gl-max-w-75 {
- max-width: $gl-spacing-scale-75;
-}
-
-.gl-md-pt-11 {
- @include media-breakpoint-up(md) {
- padding-top: $gl-spacing-scale-11 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-md-mb-6 {
- @include media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-6 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-md-mb-12 {
- @include media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-12 !important; // only need !important for now so that it overrides styles from @gitlab/ui which currently take precedence
- }
-}
-
-.gl-mt-n5 {
- margin-top: -$gl-spacing-scale-5;
-}
-
-// Utils below are very specific so cannot be part of GitLab UI
-.gl-md-mt-5 {
- @include gl-media-breakpoint-up(md) {
- margin-top: $gl-spacing-scale-5;
- }
-}
-
.gl-sm-mr-0\! {
@include gl-media-breakpoint-down(md) {
margin-right: 0 !important;
@@ -246,66 +124,20 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
}
}
-.gl-md-mb-3\! {
+.gl-md-w-15 {
@include gl-media-breakpoint-up(md) {
- margin-bottom: $gl-spacing-scale-3 !important;
+ width: $gl-spacing-scale-15;
}
}
-.gl-font-xs {
- font-size: px-to-rem(10px);
-}
-
-.gl-line-height-12 {
- line-height: px-to-rem(12px);
-}
-
-.gl-letter-spacing-06em {
- letter-spacing: 0.06em;
-}
-
-.gl-flex-flow-row-wrap {
- flex-flow: row wrap;
-}
-
-.gl-isolate {
- isolation: isolate;
-}
-
-.gl-text-transform-uppercase {
- text-transform: uppercase;
-}
-
-/*
- * The below style will be moved to @gitlab/ui by
- * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2177
- */
-.gl-gap-2 {
- gap: $gl-spacing-scale-2;
-}
-
-.gl-bg-t-gray-a-08 {
- background-color: $t-gray-a-08;
-}
-
-.gl-hover-bg-t-gray-a-08:hover {
- background-color: $t-gray-a-08;
-}
-
-.gl-inset-border-1-gray-a-08 {
- box-shadow: inset 0 0 0 $gl-border-size-1 $t-gray-a-08;
-}
-
-.gl-line-height-1 {
- line-height: 1;
-}
-
-.gl-focus:focus {
- @include gl-focus;
+.gl-md-w-20 {
+ @include gl-media-breakpoint-up(md) {
+ width: $gl-spacing-scale-20;
+ }
}
-.gl-md-justify-content-space-between {
+.gl-md-w-30 {
@include gl-media-breakpoint-up(md) {
- justify-content: space-between;
+ width: $gl-spacing-scale-30;
}
}